@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.
@@ -139,6 +139,9 @@ function valueFromOwn(input, ...keys) {
139
139
  function numberValue(value) {
140
140
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
141
141
  }
142
+ function booleanValue(value) {
143
+ return typeof value === "boolean" ? value : void 0;
144
+ }
142
145
  function horizontalBoundsOverflowPx(value) {
143
146
  if (!isRecord(value)) return 0;
144
147
  let max = maxPositiveNumber(
@@ -233,6 +236,156 @@ function toJsonValue(value) {
233
236
  if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, toJsonValue(child)]));
234
237
  return String(value);
235
238
  }
239
+ function jsonValueType(value) {
240
+ if (value === null) return "null";
241
+ if (Array.isArray(value)) return "array";
242
+ if (typeof value === "boolean") return "boolean";
243
+ if (typeof value === "number") return "number";
244
+ if (typeof value === "string") return "string";
245
+ return "object";
246
+ }
247
+ function deepJsonEqual(left, right) {
248
+ if (left === right) return true;
249
+ if (typeof left !== typeof right) return false;
250
+ if (left === null || right === null) return left === right;
251
+ if (typeof left !== "object" || typeof right !== "object") return false;
252
+ if (Array.isArray(left) || Array.isArray(right)) {
253
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
254
+ return left.every((item, index) => deepJsonEqual(item, right[index]));
255
+ }
256
+ if (!isRecord(left) || !isRecord(right)) return false;
257
+ const leftKeys = Object.keys(left).sort();
258
+ const rightKeys = Object.keys(right).sort();
259
+ if (!deepJsonEqual(leftKeys, rightKeys)) return false;
260
+ return leftKeys.every((key) => deepJsonEqual(left[key], right[key]));
261
+ }
262
+ function jsonContains(observed, expected) {
263
+ if (typeof observed === "string" && typeof expected === "string") {
264
+ return observed.includes(expected);
265
+ }
266
+ if (Array.isArray(observed)) {
267
+ return observed.some((item) => deepJsonEqual(item, expected));
268
+ }
269
+ if (isRecord(observed) && isRecord(expected)) {
270
+ return Object.entries(expected).every(([key, value]) => hasOwn(observed, key) && deepJsonEqual(observed[key], value));
271
+ }
272
+ return false;
273
+ }
274
+ function parseJsonPathSegments(path) {
275
+ let input = path.trim();
276
+ if (!input) throw new Error("path is empty");
277
+ if (input === "$") return [];
278
+ if (input.startsWith("$.")) input = input.slice(2);
279
+ else if (input.startsWith("$[")) input = input.slice(1);
280
+ const segments = [];
281
+ let token = "";
282
+ const pushToken = () => {
283
+ if (!token) return;
284
+ segments.push(token);
285
+ token = "";
286
+ };
287
+ for (let index = 0; index < input.length; index += 1) {
288
+ const char = input[index];
289
+ if (char === ".") {
290
+ pushToken();
291
+ continue;
292
+ }
293
+ if (char !== "[") {
294
+ token += char;
295
+ continue;
296
+ }
297
+ pushToken();
298
+ const closeIndex = input.indexOf("]", index + 1);
299
+ if (closeIndex === -1) throw new Error(`unterminated bracket at ${index}`);
300
+ const bracket = input.slice(index + 1, closeIndex).trim();
301
+ if (!bracket) throw new Error(`empty bracket at ${index}`);
302
+ if (/^\d+$/.test(bracket)) {
303
+ segments.push(Number(bracket));
304
+ } else if (bracket.startsWith('"') && bracket.endsWith('"') || bracket.startsWith("'") && bracket.endsWith("'")) {
305
+ const quoted = bracket.startsWith("'") ? `"${bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : bracket;
306
+ segments.push(String(JSON.parse(quoted)));
307
+ } else {
308
+ segments.push(bracket);
309
+ }
310
+ index = closeIndex;
311
+ }
312
+ pushToken();
313
+ return segments;
314
+ }
315
+ function resolveJsonPath(root, path) {
316
+ let segments;
317
+ try {
318
+ segments = parseJsonPathSegments(path);
319
+ } catch (error) {
320
+ return { exists: false, error: String(error instanceof Error ? error.message : error) };
321
+ }
322
+ let current = root;
323
+ for (const segment of segments) {
324
+ if (typeof segment === "number") {
325
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
326
+ current = current[segment];
327
+ continue;
328
+ }
329
+ if (!isRecord(current) || !hasOwn(current, segment)) return { exists: false };
330
+ current = current[segment];
331
+ }
332
+ return { exists: true, value: current };
333
+ }
334
+ function evaluateHttpStatusBodyJsonAssertion(root, assertion) {
335
+ const resolved = resolveJsonPath(root, assertion.path);
336
+ const errors = [];
337
+ const result = {
338
+ label: assertion.label || assertion.path,
339
+ path: assertion.path,
340
+ ok: true,
341
+ exists: resolved.exists,
342
+ observed_type: resolved.exists ? jsonValueType(resolved.value) : "missing"
343
+ };
344
+ if (resolved.exists) result.observed = toJsonValue(resolved.value);
345
+ if (resolved.error) errors.push(resolved.error);
346
+ if (hasOwn(assertion, "exists")) {
347
+ result.expected_exists = assertion.exists;
348
+ if (resolved.exists !== assertion.exists) errors.push(`expected exists=${assertion.exists}`);
349
+ }
350
+ if (hasOwn(assertion, "type")) {
351
+ result.type = assertion.type;
352
+ if (!resolved.exists || jsonValueType(resolved.value) !== assertion.type) errors.push(`expected type ${assertion.type}`);
353
+ }
354
+ if (hasOwn(assertion, "equals")) {
355
+ result.equals = assertion.equals;
356
+ if (!resolved.exists || !deepJsonEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
357
+ }
358
+ if (hasOwn(assertion, "not_equals")) {
359
+ result.not_equals = assertion.not_equals;
360
+ if (resolved.exists && deepJsonEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
361
+ }
362
+ if (hasOwn(assertion, "contains")) {
363
+ result.contains = assertion.contains;
364
+ if (!resolved.exists || !jsonContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
365
+ }
366
+ result.ok = errors.length === 0;
367
+ if (errors.length) result.errors = errors;
368
+ return result;
369
+ }
370
+ function evaluateHttpStatusBodyJsonAssertions(bodyText, assertions) {
371
+ const expected = assertions?.filter((assertion) => assertion.path) ?? [];
372
+ if (!expected.length) return [];
373
+ let parsed;
374
+ try {
375
+ parsed = JSON.parse(bodyText);
376
+ } catch (error) {
377
+ const message = `response body is not valid JSON: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`;
378
+ return expected.map((assertion) => ({
379
+ label: assertion.label || assertion.path,
380
+ path: assertion.path,
381
+ ok: false,
382
+ exists: false,
383
+ observed_type: "missing",
384
+ errors: [message]
385
+ }));
386
+ }
387
+ return expected.map((assertion) => evaluateHttpStatusBodyJsonAssertion(parsed, assertion));
388
+ }
236
389
  function compactProfileSetupSummaryText(value, limit = 160) {
237
390
  const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
238
391
  if (!text) return void 0;
@@ -596,6 +749,20 @@ function normalizeNetworkMock(input, index) {
596
749
  if (maxHitCount !== void 0 && effectiveRequiredHitCount > maxHitCount) {
597
750
  throw new Error(`target.network_mocks[${index}].max_hit_count cannot be less than its required hit count.`);
598
751
  }
752
+ const sequenceScopeInput = stringValue(
753
+ input.sequence_scope ?? input.sequenceScope ?? input.response_sequence_scope ?? input.responseSequenceScope
754
+ );
755
+ let sequenceScope;
756
+ if (sequenceScopeInput) {
757
+ const normalizedScope = sequenceScopeInput.toLowerCase().replace(/[-\s]+/g, "_");
758
+ if (normalizedScope === "global" || normalizedScope === "profile" || normalizedScope === "run") {
759
+ sequenceScope = "global";
760
+ } else if (normalizedScope === "viewport" || normalizedScope === "per_viewport" || normalizedScope === "viewport_scoped") {
761
+ sequenceScope = "viewport";
762
+ } else {
763
+ throw new Error(`target.network_mocks[${index}].sequence_scope must be "global" or "viewport".`);
764
+ }
765
+ }
599
766
  return {
600
767
  ...payload,
601
768
  label: normalizeName(input.label || input.name, `network-mock-${index + 1}`),
@@ -603,6 +770,7 @@ function normalizeNetworkMock(input, index) {
603
770
  method: stringValue(input.method)?.toUpperCase(),
604
771
  responses,
605
772
  repeat_responses: input.repeat_responses === true || input.repeatResponses === true || input.cycle_responses === true || input.cycleResponses === true,
773
+ sequence_scope: sequenceScope,
606
774
  required_hit_count: requiredHitCount,
607
775
  max_hit_count: maxHitCount,
608
776
  forbidden,
@@ -872,6 +1040,46 @@ function validateRegexPatterns(patterns, label) {
872
1040
  }
873
1041
  }
874
1042
  }
1043
+ function normalizeHttpStatusBodyJsonAssertions(value, label) {
1044
+ if (value === void 0) return void 0;
1045
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
1046
+ if (!value.length) throw new Error(`${label} must not be empty.`);
1047
+ return value.map((item, index) => {
1048
+ const itemLabel = `${label}[${index}]`;
1049
+ if (typeof item === "string") {
1050
+ const path2 = stringValue(item);
1051
+ if (!path2) throw new Error(`${itemLabel} path must not be empty.`);
1052
+ return { path: path2, exists: true };
1053
+ }
1054
+ if (!isRecord(item)) throw new Error(`${itemLabel} must be an object or JSON path string.`);
1055
+ const path = stringFromOwn(item, "path", "json_path", "jsonPath", "key");
1056
+ if (!path) throw new Error(`${itemLabel}.path is required.`);
1057
+ const assertion = {
1058
+ label: stringValue(item.label),
1059
+ path
1060
+ };
1061
+ const exists = booleanValue(valueFromOwn(item, "exists", "present"));
1062
+ if (exists !== void 0) assertion.exists = exists;
1063
+ const type = stringValue(valueFromOwn(item, "type", "value_type", "valueType"));
1064
+ if (type !== void 0) {
1065
+ const allowedTypes = ["array", "boolean", "null", "number", "object", "string"];
1066
+ if (!allowedTypes.includes(type)) {
1067
+ throw new Error(`${itemLabel}.type must be one of ${allowedTypes.join(", ")}.`);
1068
+ }
1069
+ assertion.type = type;
1070
+ }
1071
+ const equalsValue = valueFromOwn(item, "equals", "expected", "expected_value", "expectedValue", "value");
1072
+ if (equalsValue !== void 0) assertion.equals = toJsonValue(equalsValue);
1073
+ const notEqualsValue = valueFromOwn(item, "not_equals", "notEquals", "forbidden", "forbidden_value", "forbiddenValue");
1074
+ if (notEqualsValue !== void 0) assertion.not_equals = toJsonValue(notEqualsValue);
1075
+ const containsValue = valueFromOwn(item, "contains", "includes", "contains_value", "containsValue", "include");
1076
+ if (containsValue !== void 0) assertion.contains = toJsonValue(containsValue);
1077
+ if (assertion.exists === void 0 && assertion.type === void 0 && !hasOwn(assertion, "equals") && !hasOwn(assertion, "not_equals") && !hasOwn(assertion, "contains")) {
1078
+ assertion.exists = true;
1079
+ }
1080
+ return assertion;
1081
+ });
1082
+ }
875
1083
  function isDialogCountCheckType(type) {
876
1084
  return type === "dialog_count_equals" || type === "dialog_accept_count_equals" || type === "dialog_dismiss_count_equals";
877
1085
  }
@@ -971,6 +1179,10 @@ function normalizeCheck(input, index) {
971
1179
  `checks[${index}] body_not_patterns`
972
1180
  ) : void 0;
973
1181
  if (bodyNotPatterns?.length) validateRegexPatterns(bodyNotPatterns, `checks[${index}] body_not_patterns`);
1182
+ const bodyJsonAssertions = isHttpStatusCheck ? normalizeHttpStatusBodyJsonAssertions(
1183
+ input.body_json_assertions ?? input.bodyJsonAssertions ?? input.json_body_assertions ?? input.jsonBodyAssertions ?? input.json_assertions ?? input.jsonAssertions ?? input.response_json_assertions ?? input.responseJsonAssertions,
1184
+ `checks[${index}] body_json_assertions`
1185
+ ) : void 0;
974
1186
  if (isLinkStatusCheck) {
975
1187
  if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
976
1188
  throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
@@ -996,6 +1208,7 @@ function normalizeCheck(input, index) {
996
1208
  body_contains: bodyContains,
997
1209
  body_not_contains: bodyNotContains,
998
1210
  body_not_patterns: bodyNotPatterns,
1211
+ body_json_assertions: bodyJsonAssertions,
999
1212
  expected_texts: expectedTexts,
1000
1213
  link_selector: stringValue(input.link_selector) || stringValue(input.linkSelector),
1001
1214
  source_selector: stringValue(input.source_selector) || stringValue(input.sourceSelector),
@@ -1202,6 +1415,34 @@ function httpStatusBodyNotPatternFailures(result, check) {
1202
1415
  const observed = isRecord(result.body_not_patterns) ? result.body_not_patterns : {};
1203
1416
  return forbidden.filter((pattern) => observed[pattern] !== false);
1204
1417
  }
1418
+ function httpStatusBodyJsonAssertionFailures(result, check) {
1419
+ const expected = check.body_json_assertions?.filter((assertion) => assertion.path) ?? [];
1420
+ if (!expected.length) return [];
1421
+ if (!Array.isArray(result.body_json_assertions)) {
1422
+ return expected.map((assertion) => ({
1423
+ label: assertion.label || assertion.path,
1424
+ path: assertion.path,
1425
+ ok: false,
1426
+ exists: false,
1427
+ observed_type: "missing",
1428
+ errors: ["body_json_assertions evidence missing"]
1429
+ }));
1430
+ }
1431
+ return result.body_json_assertions.filter((assertion) => isRecord(assertion) && assertion.ok !== true).map((assertion) => ({
1432
+ label: stringValue(assertion.label) || stringValue(assertion.path) || "json assertion",
1433
+ path: stringValue(assertion.path) || "",
1434
+ ok: false,
1435
+ exists: assertion.exists === true,
1436
+ observed: hasOwn(assertion, "observed") ? toJsonValue(assertion.observed) : void 0,
1437
+ observed_type: stringValue(assertion.observed_type) || "missing",
1438
+ expected_exists: booleanValue(assertion.expected_exists),
1439
+ equals: hasOwn(assertion, "equals") ? toJsonValue(assertion.equals) : void 0,
1440
+ not_equals: hasOwn(assertion, "not_equals") ? toJsonValue(assertion.not_equals) : void 0,
1441
+ contains: hasOwn(assertion, "contains") ? toJsonValue(assertion.contains) : void 0,
1442
+ type: stringValue(assertion.type),
1443
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : void 0
1444
+ }));
1445
+ }
1205
1446
  function linkStatusResultOk(result, check) {
1206
1447
  const status = numberValue(result.status);
1207
1448
  if (!httpStatusIsAllowed(status, check)) return false;
@@ -1219,6 +1460,7 @@ function linkStatusResultOk(result, check) {
1219
1460
  if (httpStatusBodyContainsFailures(result, check).length) return false;
1220
1461
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
1221
1462
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
1463
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
1222
1464
  return true;
1223
1465
  }
1224
1466
  function responseHeader(response, name) {
@@ -1287,7 +1529,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1287
1529
  statusText = typeof response.statusText === "string" ? response.statusText : "";
1288
1530
  result.content_type = responseHeader(response, "content-type");
1289
1531
  result.content_length = responseContentLength(response);
1290
- const shouldReadBody = check.require_nonzero_bytes === true || typeof check.min_bytes === "number" || Boolean(check.body_contains?.length) || Boolean(check.body_not_contains?.length) || Boolean(check.body_not_patterns?.length);
1532
+ const shouldReadBody = check.require_nonzero_bytes === true || typeof check.min_bytes === "number" || Boolean(check.body_contains?.length) || Boolean(check.body_not_contains?.length) || Boolean(check.body_not_patterns?.length) || Boolean(check.body_json_assertions?.length);
1291
1533
  if (shouldReadBody && method !== "HEAD") {
1292
1534
  const body = await responseBodyText(response);
1293
1535
  result.bytes = body.bytes;
@@ -1300,6 +1542,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1300
1542
  if (check.body_not_patterns?.length) {
1301
1543
  result.body_not_patterns = Object.fromEntries(check.body_not_patterns.filter(Boolean).map((pattern) => [pattern, new RegExp(pattern).test(body.text)]));
1302
1544
  }
1545
+ if (check.body_json_assertions?.length) {
1546
+ result.body_json_assertions = evaluateHttpStatusBodyJsonAssertions(body.text, check.body_json_assertions);
1547
+ }
1303
1548
  }
1304
1549
  } catch (caught) {
1305
1550
  error = String(caught instanceof Error ? caught.message : caught).slice(0, 500);
@@ -1308,6 +1553,7 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1308
1553
  const bodyContainsMissing = httpStatusBodyContainsFailures(result, check);
1309
1554
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(result, check);
1310
1555
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(result, check);
1556
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(result, check);
1311
1557
  const ok = !error && linkStatusResultOk(result, check);
1312
1558
  return {
1313
1559
  index,
@@ -1326,7 +1572,9 @@ async function preflightHttpStatusCheck(check, index, targetUrl, fetchImpl) {
1326
1572
  body_not_contains: isRecord(result.body_not_contains) ? Object.fromEntries(Object.entries(result.body_not_contains).map(([key, value]) => [key, value === true])) : null,
1327
1573
  body_not_contains_found: bodyNotContainsFound,
1328
1574
  body_not_patterns: isRecord(result.body_not_patterns) ? Object.fromEntries(Object.entries(result.body_not_patterns).map(([key, value]) => [key, value === true])) : null,
1329
- body_not_patterns_found: bodyNotPatternsFound
1575
+ body_not_patterns_found: bodyNotPatternsFound,
1576
+ body_json_assertions: Array.isArray(result.body_json_assertions) ? result.body_json_assertions : null,
1577
+ body_json_assertions_failed: bodyJsonAssertionsFailed
1330
1578
  };
1331
1579
  }
1332
1580
  async function preflightRiddleProofProfileHttpStatusChecks(profile, options = {}) {
@@ -1371,6 +1619,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
1371
1619
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
1372
1620
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
1373
1621
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
1622
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
1374
1623
  if (!linkStatusResultOk(statusEvidence, check)) {
1375
1624
  failures.push({
1376
1625
  code: "http_status_failed",
@@ -1389,6 +1638,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1389
1638
  body_not_contains_found: bodyNotContainsFound,
1390
1639
  body_not_patterns: check.body_not_patterns ?? null,
1391
1640
  body_not_patterns_found: bodyNotPatternsFound,
1641
+ body_json_assertions: check.body_json_assertions ?? null,
1642
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1392
1643
  body_sample: stringValue(statusEvidence.body_sample) ?? null
1393
1644
  });
1394
1645
  }
@@ -1410,6 +1661,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
1410
1661
  body_not_contains_found: bodyNotContainsFound,
1411
1662
  body_not_patterns: isRecord(statusEvidence.body_not_patterns) ? toJsonValue(statusEvidence.body_not_patterns) : null,
1412
1663
  body_not_patterns_found: bodyNotPatternsFound,
1664
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? toJsonValue(statusEvidence.body_json_assertions) : null,
1665
+ body_json_assertions_failed: bodyJsonAssertionsFailed.map((assertion) => toJsonValue(assertion)),
1413
1666
  body_sample: stringValue(statusEvidence.body_sample) ?? null,
1414
1667
  failures
1415
1668
  };
@@ -2046,6 +2299,7 @@ function assessCheckFromEvidence(check, evidence) {
2046
2299
  body_contains: check.body_contains ?? [],
2047
2300
  body_not_contains: check.body_not_contains ?? [],
2048
2301
  body_not_patterns: check.body_not_patterns ?? [],
2302
+ body_json_assertions: toJsonValue(check.body_json_assertions ?? []),
2049
2303
  viewports: summaries.map((summary) => toJsonValue(summary)),
2050
2304
  failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
2051
2305
  },
@@ -2789,6 +3043,36 @@ function httpStatusBodyNotPatternFailures(result, check) {
2789
3043
  : {};
2790
3044
  return forbidden.filter((pattern) => observed[pattern] !== false);
2791
3045
  }
3046
+ function httpStatusBodyJsonAssertionFailures(result, check) {
3047
+ const expected = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
3048
+ if (!expected.length) return [];
3049
+ if (!Array.isArray(result.body_json_assertions)) {
3050
+ return expected.map((assertion) => ({
3051
+ label: assertion.label || assertion.path,
3052
+ path: assertion.path,
3053
+ ok: false,
3054
+ exists: false,
3055
+ observed_type: "missing",
3056
+ errors: ["body_json_assertions evidence missing"],
3057
+ }));
3058
+ }
3059
+ return result.body_json_assertions
3060
+ .filter((assertion) => assertion && typeof assertion === "object" && assertion.ok !== true)
3061
+ .map((assertion) => ({
3062
+ label: typeof assertion.label === "string" && assertion.label ? assertion.label : typeof assertion.path === "string" && assertion.path ? assertion.path : "json assertion",
3063
+ path: typeof assertion.path === "string" ? assertion.path : "",
3064
+ ok: false,
3065
+ exists: assertion.exists === true,
3066
+ observed: Object.hasOwn(assertion, "observed") ? assertion.observed : undefined,
3067
+ observed_type: typeof assertion.observed_type === "string" && assertion.observed_type ? assertion.observed_type : "missing",
3068
+ expected_exists: typeof assertion.expected_exists === "boolean" ? assertion.expected_exists : undefined,
3069
+ equals: Object.hasOwn(assertion, "equals") ? assertion.equals : undefined,
3070
+ not_equals: Object.hasOwn(assertion, "not_equals") ? assertion.not_equals : undefined,
3071
+ contains: Object.hasOwn(assertion, "contains") ? assertion.contains : undefined,
3072
+ type: typeof assertion.type === "string" ? assertion.type : undefined,
3073
+ errors: Array.isArray(assertion.errors) ? assertion.errors.map(String) : undefined,
3074
+ }));
3075
+ }
2792
3076
  function linkStatusResultOk(result, check) {
2793
3077
  if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2794
3078
  if (!httpStatusIsAllowed(result.status, check)) return false;
@@ -2806,6 +3090,7 @@ function linkStatusResultOk(result, check) {
2806
3090
  if (httpStatusBodyContainsFailures(result, check).length) return false;
2807
3091
  if (httpStatusBodyNotContainsFailures(result, check).length) return false;
2808
3092
  if (httpStatusBodyNotPatternFailures(result, check).length) return false;
3093
+ if (httpStatusBodyJsonAssertionFailures(result, check).length) return false;
2809
3094
  return true;
2810
3095
  }
2811
3096
  function summarizeHttpStatusEvidence(viewport, check) {
@@ -2826,6 +3111,7 @@ function summarizeHttpStatusEvidence(viewport, check) {
2826
3111
  const bodyContainsMissing = httpStatusBodyContainsFailures(statusEvidence, check);
2827
3112
  const bodyNotContainsFound = httpStatusBodyNotContainsFailures(statusEvidence, check);
2828
3113
  const bodyNotPatternsFound = httpStatusBodyNotPatternFailures(statusEvidence, check);
3114
+ const bodyJsonAssertionsFailed = httpStatusBodyJsonAssertionFailures(statusEvidence, check);
2829
3115
  if (!linkStatusResultOk(statusEvidence, check)) {
2830
3116
  failures.push({
2831
3117
  code: "http_status_failed",
@@ -2844,6 +3130,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2844
3130
  body_not_contains_found: bodyNotContainsFound,
2845
3131
  body_not_patterns: Array.isArray(check.body_not_patterns) ? check.body_not_patterns : null,
2846
3132
  body_not_patterns_found: bodyNotPatternsFound,
3133
+ body_json_assertions: Array.isArray(check.body_json_assertions) ? check.body_json_assertions : null,
3134
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2847
3135
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2848
3136
  });
2849
3137
  }
@@ -2871,6 +3159,8 @@ function summarizeHttpStatusEvidence(viewport, check) {
2871
3159
  ? statusEvidence.body_not_patterns
2872
3160
  : null,
2873
3161
  body_not_patterns_found: bodyNotPatternsFound,
3162
+ body_json_assertions: Array.isArray(statusEvidence.body_json_assertions) ? statusEvidence.body_json_assertions : null,
3163
+ body_json_assertions_failed: bodyJsonAssertionsFailed,
2874
3164
  body_sample: typeof statusEvidence.body_sample === "string" ? statusEvidence.body_sample : null,
2875
3165
  failures,
2876
3166
  };
@@ -4254,6 +4544,7 @@ async function setupLocatorVisible(locator, index) {
4254
4544
  async function registerNetworkMocks(mocks) {
4255
4545
  for (const mock of mocks || []) {
4256
4546
  let hitCount = 0;
4547
+ const scopedHitCounts = {};
4257
4548
  await page.route(mock.url, async (route) => {
4258
4549
  const request = route.request();
4259
4550
  const method = request.method ? request.method() : "";
@@ -4269,8 +4560,13 @@ async function registerNetworkMocks(mocks) {
4269
4560
  const responses = Array.isArray(mock.responses) ? mock.responses : [];
4270
4561
  const hitIndex = hitCount;
4271
4562
  hitCount += 1;
4563
+ const sequenceScope = mock.sequence_scope === "viewport" ? "viewport" : "global";
4564
+ const viewportName = activeViewportName || null;
4565
+ const sequenceScopeKey = sequenceScope === "viewport" ? (viewportName || "__unknown_viewport__") : "__global__";
4566
+ const sequenceHitIndex = sequenceScope === "viewport" ? (scopedHitCounts[sequenceScopeKey] || 0) : hitIndex;
4567
+ if (sequenceScope === "viewport") scopedHitCounts[sequenceScopeKey] = sequenceHitIndex + 1;
4272
4568
  const sequenceResponseIndex = responses.length
4273
- ? (mock.repeat_responses ? hitIndex % responses.length : Math.min(hitIndex, responses.length - 1))
4569
+ ? (mock.repeat_responses ? sequenceHitIndex % responses.length : Math.min(sequenceHitIndex, responses.length - 1))
4274
4570
  : null;
4275
4571
  let responseIndex = sequenceResponseIndex;
4276
4572
  let responseSelection = responseIndex === null ? "mock" : "sequence";
@@ -4305,11 +4601,14 @@ async function registerNetworkMocks(mocks) {
4305
4601
  label: mock.label,
4306
4602
  response_label: response.label || null,
4307
4603
  hit_index: hitIndex,
4604
+ sequence_hit_index: responseIndex === null ? undefined : sequenceHitIndex,
4605
+ sequence_scope: responseIndex === null ? undefined : sequenceScope,
4606
+ viewport: viewportName,
4308
4607
  response_index: responseIndex,
4309
4608
  sequence_response_index: responseSelection === "request_body" ? sequenceResponseIndex : undefined,
4310
4609
  response_selection: responseIndex === null ? null : responseSelection,
4311
- sequence_reused: responseSelection === "sequence" && responseIndex !== null && !mock.repeat_responses && hitIndex >= responses.length,
4312
- sequence_cycle: responseSelection === "sequence" && responseIndex !== null && mock.repeat_responses === true && hitIndex >= responses.length,
4610
+ sequence_reused: responseSelection === "sequence" && responseIndex !== null && !mock.repeat_responses && sequenceHitIndex >= responses.length,
4611
+ sequence_cycle: responseSelection === "sequence" && responseIndex !== null && mock.repeat_responses === true && sequenceHitIndex >= responses.length,
4313
4612
  url: request.url(),
4314
4613
  method,
4315
4614
  };
@@ -4351,6 +4650,7 @@ async function registerNetworkMocks(mocks) {
4351
4650
  });
4352
4651
  }
4353
4652
  }
4653
+ let activeViewportName = null;
4354
4654
  async function executeSetupAction(action, ordinal, viewport) {
4355
4655
  const type = setupActionType(action);
4356
4656
  const frameSelector = setupFrameSelector(action);
@@ -4963,6 +5263,155 @@ function linkProbeResponseFields(response, method) {
4963
5263
  content_length: contentLength,
4964
5264
  };
4965
5265
  }
5266
+ function jsonProbeValueType(value) {
5267
+ if (value === null) return "null";
5268
+ if (Array.isArray(value)) return "array";
5269
+ if (typeof value === "boolean") return "boolean";
5270
+ if (typeof value === "number") return "number";
5271
+ if (typeof value === "string") return "string";
5272
+ return "object";
5273
+ }
5274
+ function jsonProbeDeepEqual(left, right) {
5275
+ if (left === right) return true;
5276
+ if (typeof left !== typeof right) return false;
5277
+ if (left === null || right === null) return left === right;
5278
+ if (typeof left !== "object" || typeof right !== "object") return false;
5279
+ if (Array.isArray(left) || Array.isArray(right)) {
5280
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
5281
+ return left.every((item, index) => jsonProbeDeepEqual(item, right[index]));
5282
+ }
5283
+ const leftKeys = Object.keys(left).sort();
5284
+ const rightKeys = Object.keys(right).sort();
5285
+ if (!jsonProbeDeepEqual(leftKeys, rightKeys)) return false;
5286
+ return leftKeys.every((key) => jsonProbeDeepEqual(left[key], right[key]));
5287
+ }
5288
+ function jsonProbeContains(observed, expected) {
5289
+ if (typeof observed === "string" && typeof expected === "string") return observed.includes(expected);
5290
+ if (Array.isArray(observed)) return observed.some((item) => jsonProbeDeepEqual(item, expected));
5291
+ if (observed && expected && typeof observed === "object" && typeof expected === "object" && !Array.isArray(observed) && !Array.isArray(expected)) {
5292
+ return Object.entries(expected).every(([key, value]) => Object.hasOwn(observed, key) && jsonProbeDeepEqual(observed[key], value));
5293
+ }
5294
+ return false;
5295
+ }
5296
+ function parseJsonProbePathSegments(path) {
5297
+ let input = String(path || "").trim();
5298
+ if (!input) throw new Error("path is empty");
5299
+ if (input === "$") return [];
5300
+ if (input.startsWith("$.")) input = input.slice(2);
5301
+ else if (input.startsWith("$[")) input = input.slice(1);
5302
+ const segments = [];
5303
+ let token = "";
5304
+ const pushToken = () => {
5305
+ if (!token) return;
5306
+ segments.push(token);
5307
+ token = "";
5308
+ };
5309
+ for (let index = 0; index < input.length; index += 1) {
5310
+ const char = input[index];
5311
+ if (char === ".") {
5312
+ pushToken();
5313
+ continue;
5314
+ }
5315
+ if (char !== "[") {
5316
+ token += char;
5317
+ continue;
5318
+ }
5319
+ pushToken();
5320
+ const closeIndex = input.indexOf("]", index + 1);
5321
+ if (closeIndex === -1) throw new Error("unterminated bracket at " + index);
5322
+ const bracket = input.slice(index + 1, closeIndex).trim();
5323
+ if (!bracket) throw new Error("empty bracket at " + index);
5324
+ if (/^\d+$/.test(bracket)) {
5325
+ segments.push(Number(bracket));
5326
+ } else if ((bracket.startsWith('"') && bracket.endsWith('"')) || (bracket.startsWith("'") && bracket.endsWith("'"))) {
5327
+ const quoted = bracket.startsWith("'")
5328
+ ? '"' + bracket.slice(1, -1).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + '"'
5329
+ : bracket;
5330
+ segments.push(String(JSON.parse(quoted)));
5331
+ } else {
5332
+ segments.push(bracket);
5333
+ }
5334
+ index = closeIndex;
5335
+ }
5336
+ pushToken();
5337
+ return segments;
5338
+ }
5339
+ function resolveJsonProbePath(root, path) {
5340
+ let segments;
5341
+ try {
5342
+ segments = parseJsonProbePathSegments(path);
5343
+ } catch (error) {
5344
+ return { exists: false, error: String(error && error.message ? error.message : error) };
5345
+ }
5346
+ let current = root;
5347
+ for (const segment of segments) {
5348
+ if (typeof segment === "number") {
5349
+ if (!Array.isArray(current) || segment < 0 || segment >= current.length) return { exists: false };
5350
+ current = current[segment];
5351
+ continue;
5352
+ }
5353
+ if (!current || typeof current !== "object" || Array.isArray(current) || !Object.hasOwn(current, segment)) {
5354
+ return { exists: false };
5355
+ }
5356
+ current = current[segment];
5357
+ }
5358
+ return { exists: true, value: current };
5359
+ }
5360
+ function evaluateJsonProbeAssertion(root, assertion) {
5361
+ const resolved = resolveJsonProbePath(root, assertion.path);
5362
+ const errors = [];
5363
+ const result = {
5364
+ label: assertion.label || assertion.path,
5365
+ path: assertion.path,
5366
+ ok: true,
5367
+ exists: resolved.exists,
5368
+ observed_type: resolved.exists ? jsonProbeValueType(resolved.value) : "missing",
5369
+ };
5370
+ if (resolved.exists) result.observed = resolved.value;
5371
+ if (resolved.error) errors.push(resolved.error);
5372
+ if (Object.hasOwn(assertion, "exists")) {
5373
+ result.expected_exists = assertion.exists;
5374
+ if (resolved.exists !== assertion.exists) errors.push("expected exists=" + assertion.exists);
5375
+ }
5376
+ if (Object.hasOwn(assertion, "type")) {
5377
+ result.type = assertion.type;
5378
+ if (!resolved.exists || jsonProbeValueType(resolved.value) !== assertion.type) errors.push("expected type " + assertion.type);
5379
+ }
5380
+ if (Object.hasOwn(assertion, "equals")) {
5381
+ result.equals = assertion.equals;
5382
+ if (!resolved.exists || !jsonProbeDeepEqual(resolved.value, assertion.equals)) errors.push("expected JSON value equality");
5383
+ }
5384
+ if (Object.hasOwn(assertion, "not_equals")) {
5385
+ result.not_equals = assertion.not_equals;
5386
+ if (resolved.exists && jsonProbeDeepEqual(resolved.value, assertion.not_equals)) errors.push("expected JSON value inequality");
5387
+ }
5388
+ if (Object.hasOwn(assertion, "contains")) {
5389
+ result.contains = assertion.contains;
5390
+ if (!resolved.exists || !jsonProbeContains(resolved.value, assertion.contains)) errors.push("expected JSON value containment");
5391
+ }
5392
+ result.ok = errors.length === 0;
5393
+ if (errors.length) result.errors = errors;
5394
+ return result;
5395
+ }
5396
+ function evaluateJsonProbeAssertions(text, assertions) {
5397
+ const expected = Array.isArray(assertions) ? assertions.filter((assertion) => assertion && assertion.path) : [];
5398
+ if (!expected.length) return [];
5399
+ let parsed;
5400
+ try {
5401
+ parsed = JSON.parse(text);
5402
+ } catch (error) {
5403
+ const message = "response body is not valid JSON: " + String(error && error.message ? error.message : error).slice(0, 200);
5404
+ return expected.map((assertion) => ({
5405
+ label: assertion.label || assertion.path,
5406
+ path: assertion.path,
5407
+ ok: false,
5408
+ exists: false,
5409
+ observed_type: "missing",
5410
+ errors: [message],
5411
+ }));
5412
+ }
5413
+ return expected.map((assertion) => evaluateJsonProbeAssertion(parsed, assertion));
5414
+ }
4966
5415
  async function collectHttpStatus(check) {
4967
5416
  const url = httpStatusRequestUrl(check, page.url() || targetUrl);
4968
5417
  const method = httpStatusMethod(check);
@@ -4979,6 +5428,7 @@ async function collectHttpStatus(check) {
4979
5428
  const bodyContains = Array.isArray(check.body_contains) ? check.body_contains.filter(Boolean) : [];
4980
5429
  const bodyNotContains = Array.isArray(check.body_not_contains) ? check.body_not_contains.filter(Boolean) : [];
4981
5430
  const bodyNotPatterns = Array.isArray(check.body_not_patterns) ? check.body_not_patterns.filter(Boolean) : [];
5431
+ const bodyJsonAssertions = Array.isArray(check.body_json_assertions) ? check.body_json_assertions.filter((assertion) => assertion && assertion.path) : [];
4982
5432
  const options = {
4983
5433
  method,
4984
5434
  redirect: "follow",
@@ -5004,17 +5454,18 @@ async function collectHttpStatus(check) {
5004
5454
  Object.assign(result, linkProbeResponseFields(response, method));
5005
5455
  result.url = url;
5006
5456
  result.status_text = response.statusText || "";
5007
- const shouldReadBody = check.require_nonzero_bytes === true || (typeof check.min_bytes === "number" && Number.isFinite(check.min_bytes)) || bodyContains.length > 0 || bodyNotContains.length > 0 || bodyNotPatterns.length > 0;
5457
+ const shouldReadBody = check.require_nonzero_bytes === true || (typeof check.min_bytes === "number" && Number.isFinite(check.min_bytes)) || bodyContains.length > 0 || bodyNotContains.length > 0 || bodyNotPatterns.length > 0 || bodyJsonAssertions.length > 0;
5008
5458
  if (shouldReadBody) {
5009
5459
  try {
5010
5460
  const buffer = await response.arrayBuffer();
5011
5461
  result.bytes = buffer.byteLength;
5012
- if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length) {
5462
+ if (bodyContains.length || bodyNotContains.length || bodyNotPatterns.length || bodyJsonAssertions.length) {
5013
5463
  const text = new TextDecoder().decode(buffer);
5014
5464
  result.body_sample = text.slice(0, 1000);
5015
5465
  if (bodyContains.length) result.body_contains = Object.fromEntries(bodyContains.map((expected) => [expected, text.includes(expected)]));
5016
5466
  if (bodyNotContains.length) result.body_not_contains = Object.fromEntries(bodyNotContains.map((forbidden) => [forbidden, text.includes(forbidden)]));
5017
5467
  if (bodyNotPatterns.length) result.body_not_patterns = Object.fromEntries(bodyNotPatterns.map((pattern) => [pattern, new RegExp(pattern).test(text)]));
5468
+ if (bodyJsonAssertions.length) result.body_json_assertions = evaluateJsonProbeAssertions(text, bodyJsonAssertions);
5018
5469
  }
5019
5470
  } catch (error) {
5020
5471
  result.error = String(error && error.message ? error.message : error).slice(0, 500);
@@ -5027,6 +5478,7 @@ async function collectHttpStatus(check) {
5027
5478
  && (!bodyContains.length || bodyContains.every((expected) => result.body_contains && result.body_contains[expected] === true))
5028
5479
  && (!bodyNotContains.length || bodyNotContains.every((forbidden) => result.body_not_contains && result.body_not_contains[forbidden] === false))
5029
5480
  && (!bodyNotPatterns.length || bodyNotPatterns.every((pattern) => result.body_not_patterns && result.body_not_patterns[pattern] === false))
5481
+ && (!bodyJsonAssertions.length || (Array.isArray(result.body_json_assertions) && result.body_json_assertions.every((assertion) => assertion.ok === true)))
5030
5482
  && !result.error;
5031
5483
  return result;
5032
5484
  } catch (error) {
@@ -5613,6 +6065,7 @@ async function collectRouteInventory(check, viewport) {
5613
6065
  };
5614
6066
  }
5615
6067
  async function captureViewport(viewport) {
6068
+ activeViewportName = viewport && viewport.name ? viewport.name : null;
5616
6069
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
5617
6070
  let httpStatus = null;
5618
6071
  let navigationError;