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