@riddledc/riddle-proof 0.7.88 → 0.7.90
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 +26 -1
- package/dist/{chunk-CEBHV23V.js → chunk-4FMBHM4E.js} +484 -2
- package/dist/cli.cjs +515 -2
- package/dist/cli.js +32 -1
- package/dist/index.cjs +484 -2
- package/dist/index.js +1 -1
- package/dist/profile.cjs +484 -2
- package/dist/profile.d.cts +9 -1
- package/dist/profile.d.ts +9 -1
- package/dist/profile.js +1 -1
- package/dist/proof-run-engine.d.cts +3 -3
- package/dist/proof-run-engine.d.ts +3 -3
- package/package.json +1 -1
- package/runtime/lib/verify.py +71 -13
- package/runtime/tests/recon_verify_smoke.py +23 -0
package/README.md
CHANGED
|
@@ -103,7 +103,9 @@ targeted copy or UI changes, the visual gate can use a lower targeted threshold
|
|
|
103
103
|
only when route/text semantics match the requested change. When before/after
|
|
104
104
|
image artifacts can be compared directly, the runtime also records the changed
|
|
105
105
|
region bounding box and refuses the targeted threshold if that exact region is
|
|
106
|
-
too broad for a localized UI change.
|
|
106
|
+
too broad for a localized UI change. External visual-delta payloads can provide
|
|
107
|
+
that region with common field names such as `changed_region`, `bounds`, `bbox`,
|
|
108
|
+
`boundingBox`, `xMin`/`xMax`, or `x1`/`x2`. Missing or unmeasured visual deltas
|
|
107
109
|
continue through evidence recovery instead of being treated as a passing proof.
|
|
108
110
|
|
|
109
111
|
## CI / Profile Mode
|
|
@@ -448,6 +450,29 @@ scroll width, client width, measured horizontal overflow, and top visible
|
|
|
448
450
|
overflow offenders. This keeps embedded-player audits in profile mode instead
|
|
449
451
|
of requiring bespoke iframe inspection scripts.
|
|
450
452
|
|
|
453
|
+
Use `link_status` for public link or asset audits where selected `href` / `src`
|
|
454
|
+
URLs must still resolve. This is useful for Good Catch pages, documentation
|
|
455
|
+
indexes, saved-game galleries, and other public proof surfaces where stale
|
|
456
|
+
artifact links should fail the profile:
|
|
457
|
+
|
|
458
|
+
```json
|
|
459
|
+
{
|
|
460
|
+
"type": "link_status",
|
|
461
|
+
"selector": "a[href*='/artifacts/'], img[src*='/artifacts/']",
|
|
462
|
+
"expected_count": 88,
|
|
463
|
+
"same_origin_only": true,
|
|
464
|
+
"require_nonzero_bytes": true,
|
|
465
|
+
"max_links": 150
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The check defaults to `a[href]`, deduplicates URLs, probes up to 100 selected
|
|
470
|
+
URLs, and treats HTTP `2xx` / `3xx` responses as healthy. Use
|
|
471
|
+
`allowed_statuses` or `expected_status` when a narrower status contract is
|
|
472
|
+
intentional, `min_count` for lower-bound audits, and `max_links` when the
|
|
473
|
+
selected set is intentionally larger than 100. `artifact_link_status` is an
|
|
474
|
+
alias with the same behavior for profiles that want artifact-specific wording.
|
|
475
|
+
|
|
451
476
|
Use the `route_inventory` check for source-page route coverage audits where a
|
|
452
477
|
navigation surface must expose a known set of routes and each route must load
|
|
453
478
|
both directly and through real link clicks:
|
|
@@ -27,6 +27,8 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
|
|
|
27
27
|
"frame_no_horizontal_overflow",
|
|
28
28
|
"text_visible",
|
|
29
29
|
"text_absent",
|
|
30
|
+
"link_status",
|
|
31
|
+
"artifact_link_status",
|
|
30
32
|
"route_inventory",
|
|
31
33
|
"no_horizontal_overflow",
|
|
32
34
|
"no_mobile_horizontal_overflow",
|
|
@@ -691,6 +693,29 @@ function normalizeStringList(value, label) {
|
|
|
691
693
|
if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
|
|
692
694
|
return values;
|
|
693
695
|
}
|
|
696
|
+
function normalizeHttpStatus(value, label) {
|
|
697
|
+
if (value === void 0) return void 0;
|
|
698
|
+
const status = numberValue(value);
|
|
699
|
+
if (status === void 0 || !Number.isInteger(status) || status < 100 || status > 599) {
|
|
700
|
+
throw new Error(`${label} must be an HTTP status code.`);
|
|
701
|
+
}
|
|
702
|
+
return status;
|
|
703
|
+
}
|
|
704
|
+
function normalizeHttpStatuses(value, label) {
|
|
705
|
+
if (value === void 0) return void 0;
|
|
706
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
|
|
707
|
+
if (!value.length) throw new Error(`${label} must not be empty.`);
|
|
708
|
+
const statuses = value.map((item, index) => normalizeHttpStatus(item, `${label}[${index}]`));
|
|
709
|
+
return Array.from(new Set(statuses));
|
|
710
|
+
}
|
|
711
|
+
function normalizePositiveInteger(value, label, max) {
|
|
712
|
+
if (value === void 0) return void 0;
|
|
713
|
+
const number = numberValue(value);
|
|
714
|
+
if (number === void 0 || !Number.isInteger(number) || number < 1 || max !== void 0 && number > max) {
|
|
715
|
+
throw new Error(`${label} must be an integer from 1 to ${max ?? "unbounded"}.`);
|
|
716
|
+
}
|
|
717
|
+
return number;
|
|
718
|
+
}
|
|
694
719
|
function validateRegexPatterns(patterns, label) {
|
|
695
720
|
for (const pattern of patterns || []) {
|
|
696
721
|
try {
|
|
@@ -734,7 +759,8 @@ function normalizeCheck(input, index) {
|
|
|
734
759
|
if (type === "url_search_param_equals" && expectedValue === void 0) {
|
|
735
760
|
throw new Error(`checks[${index}] url_search_param_equals requires expected_value.`);
|
|
736
761
|
}
|
|
737
|
-
|
|
762
|
+
const minCount = numberValue(input.min_count) ?? numberValue(input.minCount);
|
|
763
|
+
if (type === "selector_count_at_least" && minCount === void 0) {
|
|
738
764
|
throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
|
|
739
765
|
}
|
|
740
766
|
const expectedCount = numberValue(input.expected_count) ?? numberValue(input.expectedCount) ?? numberValue(input.count);
|
|
@@ -750,6 +776,21 @@ function normalizeCheck(input, index) {
|
|
|
750
776
|
if (type === "route_inventory" && !expectedRoutes?.length) {
|
|
751
777
|
throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
|
|
752
778
|
}
|
|
779
|
+
const isLinkStatusCheck = type === "link_status" || type === "artifact_link_status";
|
|
780
|
+
const expectedStatus = isLinkStatusCheck ? normalizeHttpStatus(input.expected_status ?? input.expectedStatus ?? input.status, `checks[${index}] expected_status`) : void 0;
|
|
781
|
+
const allowedStatuses = isLinkStatusCheck ? normalizeHttpStatuses(
|
|
782
|
+
input.allowed_statuses ?? input.allowedStatuses ?? input.expected_statuses ?? input.expectedStatuses,
|
|
783
|
+
`checks[${index}] allowed_statuses`
|
|
784
|
+
) : void 0;
|
|
785
|
+
const maxLinks = isLinkStatusCheck ? normalizePositiveInteger(input.max_links ?? input.maxLinks ?? input.limit, `checks[${index}] max_links`, 500) : void 0;
|
|
786
|
+
if (isLinkStatusCheck) {
|
|
787
|
+
if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
|
|
788
|
+
throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
|
|
789
|
+
}
|
|
790
|
+
if (expectedCount !== void 0 && (!Number.isInteger(expectedCount) || expectedCount < 0)) {
|
|
791
|
+
throw new Error(`checks[${index}] ${type} expected_count must be a non-negative integer.`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
753
794
|
return {
|
|
754
795
|
type,
|
|
755
796
|
label: stringValue(input.label),
|
|
@@ -775,8 +816,15 @@ function normalizeCheck(input, index) {
|
|
|
775
816
|
allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
|
|
776
817
|
allowed_page_error_texts: normalizeStringList(input.allowed_page_error_texts ?? input.allowedPageErrorTexts ?? input.allow_page_error_texts ?? input.allowPageErrorTexts, `checks[${index}] allowed_page_error_texts`),
|
|
777
818
|
allowed_page_error_patterns: normalizeStringList(input.allowed_page_error_patterns ?? input.allowedPageErrorPatterns ?? input.allow_page_error_patterns ?? input.allowPageErrorPatterns, `checks[${index}] allowed_page_error_patterns`),
|
|
778
|
-
min_count:
|
|
819
|
+
min_count: minCount,
|
|
779
820
|
expected_count: expectedCount,
|
|
821
|
+
expected_status: expectedStatus,
|
|
822
|
+
allowed_statuses: allowedStatuses,
|
|
823
|
+
max_links: maxLinks,
|
|
824
|
+
same_origin_only: isLinkStatusCheck ? input.same_origin_only === true || input.sameOriginOnly === true : void 0,
|
|
825
|
+
dedupe: isLinkStatusCheck ? input.dedupe === false ? false : true : void 0,
|
|
826
|
+
require_nonzero_bytes: isLinkStatusCheck ? input.require_nonzero_bytes === true || input.requireNonzeroBytes === true : void 0,
|
|
827
|
+
allow_get_fallback: isLinkStatusCheck ? input.allow_get_fallback === false || input.allowGetFallback === false ? false : true : void 0,
|
|
780
828
|
max_overflow_px: numberValue(input.max_overflow_px),
|
|
781
829
|
timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
|
|
782
830
|
run_direct_routes: input.run_direct_routes === false || input.runDirectRoutes === false ? false : true,
|
|
@@ -869,6 +917,97 @@ function checkLabel(check) {
|
|
|
869
917
|
function selectorKey(check) {
|
|
870
918
|
return check.selector || "";
|
|
871
919
|
}
|
|
920
|
+
function linkStatusSelector(check) {
|
|
921
|
+
return check.selector || check.link_selector || "a[href]";
|
|
922
|
+
}
|
|
923
|
+
function linkStatusAllowedStatuses(check) {
|
|
924
|
+
if (check.allowed_statuses?.length) return check.allowed_statuses;
|
|
925
|
+
if (check.expected_status !== void 0) return [check.expected_status];
|
|
926
|
+
return void 0;
|
|
927
|
+
}
|
|
928
|
+
function linkStatusIsAllowed(status, check) {
|
|
929
|
+
if (status === void 0) return false;
|
|
930
|
+
const allowed = linkStatusAllowedStatuses(check);
|
|
931
|
+
return allowed?.length ? allowed.includes(status) : status >= 200 && status < 400;
|
|
932
|
+
}
|
|
933
|
+
function linkStatusResultOk(result, check) {
|
|
934
|
+
const status = numberValue(result.status);
|
|
935
|
+
if (!linkStatusIsAllowed(status, check)) return false;
|
|
936
|
+
if (stringValue(result.error)) return false;
|
|
937
|
+
if (result.ok === false) return false;
|
|
938
|
+
if (check.require_nonzero_bytes) {
|
|
939
|
+
const bytes = numberValue(result.bytes);
|
|
940
|
+
const contentLength = numberValue(result.content_length);
|
|
941
|
+
return bytes !== void 0 && bytes > 0 || contentLength !== void 0 && contentLength > 0;
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
function linkStatusEvidenceForCheck(viewport, check) {
|
|
946
|
+
const evidence = viewport.link_statuses?.[linkStatusSelector(check)];
|
|
947
|
+
return isRecord(evidence) ? evidence : void 0;
|
|
948
|
+
}
|
|
949
|
+
function summarizeLinkStatusEvidence(viewport, check) {
|
|
950
|
+
const linkEvidence = linkStatusEvidenceForCheck(viewport, check);
|
|
951
|
+
if (!linkEvidence) {
|
|
952
|
+
return {
|
|
953
|
+
viewport: viewport.name,
|
|
954
|
+
selector: linkStatusSelector(check),
|
|
955
|
+
total_count: 0,
|
|
956
|
+
ok_count: 0,
|
|
957
|
+
failed_count: 1,
|
|
958
|
+
failures: [{ code: "link_status_evidence_missing" }]
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter(isRecord) : [];
|
|
962
|
+
const totalCount = numberValue(linkEvidence.total_count) ?? results.length;
|
|
963
|
+
const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
|
|
964
|
+
const failures = results.filter((result) => !linkStatusResultOk(result, check)).map((result) => ({
|
|
965
|
+
code: "link_status_failed",
|
|
966
|
+
url: stringValue(result.url) ?? null,
|
|
967
|
+
status: numberValue(result.status) ?? null,
|
|
968
|
+
method: stringValue(result.method) ?? null,
|
|
969
|
+
error: stringValue(result.error) ?? null,
|
|
970
|
+
bytes: numberValue(result.bytes) ?? numberValue(result.content_length) ?? null
|
|
971
|
+
}));
|
|
972
|
+
if (stringValue(linkEvidence.error)) {
|
|
973
|
+
failures.push({ code: "link_status_capture_failed", error: stringValue(linkEvidence.error) ?? "" });
|
|
974
|
+
}
|
|
975
|
+
const recordedFailedCount = numberValue(linkEvidence.failed_count) ?? 0;
|
|
976
|
+
if (!failures.length && recordedFailedCount > 0) {
|
|
977
|
+
const recordedFailures = Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [];
|
|
978
|
+
failures.push({
|
|
979
|
+
code: "link_status_recorded_failures",
|
|
980
|
+
failed_count: recordedFailedCount,
|
|
981
|
+
failures: toJsonValue(recordedFailures)
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
if (linkEvidence.truncated === true) {
|
|
985
|
+
failures.push({
|
|
986
|
+
code: "link_status_probe_truncated",
|
|
987
|
+
discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
|
|
988
|
+
max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
if (check.expected_count !== void 0 && totalCount !== check.expected_count) {
|
|
992
|
+
failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
|
|
993
|
+
}
|
|
994
|
+
if (check.min_count !== void 0 && totalCount < check.min_count) {
|
|
995
|
+
failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
|
|
996
|
+
}
|
|
997
|
+
const statusCounts = isRecord(linkEvidence.status_counts) ? linkEvidence.status_counts : {};
|
|
998
|
+
return {
|
|
999
|
+
viewport: viewport.name,
|
|
1000
|
+
selector: linkStatusSelector(check),
|
|
1001
|
+
total_count: totalCount,
|
|
1002
|
+
discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
|
|
1003
|
+
ok_count: numberValue(linkEvidence.ok_count) ?? okCount,
|
|
1004
|
+
failed_count: failures.length,
|
|
1005
|
+
truncated: linkEvidence.truncated === true,
|
|
1006
|
+
max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100,
|
|
1007
|
+
status_counts: statusCounts,
|
|
1008
|
+
failures: failures.slice(0, 20)
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
872
1011
|
function textKey(check) {
|
|
873
1012
|
return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
|
|
874
1013
|
}
|
|
@@ -1348,6 +1487,26 @@ function assessCheckFromEvidence(check, evidence) {
|
|
|
1348
1487
|
message: failed ? `Text assertion failed in ${failed} viewport(s).` : void 0
|
|
1349
1488
|
};
|
|
1350
1489
|
}
|
|
1490
|
+
if (check.type === "link_status" || check.type === "artifact_link_status") {
|
|
1491
|
+
const selector = linkStatusSelector(check);
|
|
1492
|
+
const summaries = viewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
|
|
1493
|
+
const failed = summaries.filter((summary) => (numberValue(summary.failed_count) ?? 0) > 0);
|
|
1494
|
+
return {
|
|
1495
|
+
type: check.type,
|
|
1496
|
+
label: checkLabel(check),
|
|
1497
|
+
status: failed.length ? "failed" : "passed",
|
|
1498
|
+
evidence: {
|
|
1499
|
+
selector,
|
|
1500
|
+
expected_count: check.expected_count ?? null,
|
|
1501
|
+
min_count: check.min_count ?? null,
|
|
1502
|
+
allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
|
|
1503
|
+
require_nonzero_bytes: check.require_nonzero_bytes === true,
|
|
1504
|
+
viewports: summaries.map((summary) => toJsonValue(summary)),
|
|
1505
|
+
failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
|
|
1506
|
+
},
|
|
1507
|
+
message: failed.length ? `Link status failed in ${failed.length} viewport(s).` : void 0
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1351
1510
|
if (check.type === "route_inventory") {
|
|
1352
1511
|
const inventories = viewports.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory })).filter((item) => isRecord(item.inventory));
|
|
1353
1512
|
if (!inventories.length) {
|
|
@@ -1930,6 +2089,98 @@ function textOrderMatch(texts, expectedTexts) {
|
|
|
1930
2089
|
}
|
|
1931
2090
|
return { matched: true, positions };
|
|
1932
2091
|
}
|
|
2092
|
+
function linkStatusSelector(check) {
|
|
2093
|
+
return check.selector || check.link_selector || "a[href]";
|
|
2094
|
+
}
|
|
2095
|
+
function linkStatusAllowedStatuses(check) {
|
|
2096
|
+
if (Array.isArray(check.allowed_statuses) && check.allowed_statuses.length) return check.allowed_statuses;
|
|
2097
|
+
if (typeof check.expected_status === "number" && Number.isFinite(check.expected_status)) return [check.expected_status];
|
|
2098
|
+
return undefined;
|
|
2099
|
+
}
|
|
2100
|
+
function linkStatusIsAllowed(status, check) {
|
|
2101
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
2102
|
+
const allowed = linkStatusAllowedStatuses(check);
|
|
2103
|
+
return Array.isArray(allowed) && allowed.length ? allowed.includes(status) : status >= 200 && status < 400;
|
|
2104
|
+
}
|
|
2105
|
+
function linkStatusResultOk(result, check) {
|
|
2106
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) return false;
|
|
2107
|
+
if (!linkStatusIsAllowed(result.status, check)) return false;
|
|
2108
|
+
if (typeof result.error === "string" && result.error.trim()) return false;
|
|
2109
|
+
if (result.ok === false) return false;
|
|
2110
|
+
if (check.require_nonzero_bytes === true) {
|
|
2111
|
+
const bytes = typeof result.bytes === "number" && Number.isFinite(result.bytes) ? result.bytes : undefined;
|
|
2112
|
+
const contentLength = typeof result.content_length === "number" && Number.isFinite(result.content_length) ? result.content_length : undefined;
|
|
2113
|
+
return (bytes !== undefined && bytes > 0) || (contentLength !== undefined && contentLength > 0);
|
|
2114
|
+
}
|
|
2115
|
+
return true;
|
|
2116
|
+
}
|
|
2117
|
+
function summarizeLinkStatusEvidence(viewport, check) {
|
|
2118
|
+
const selector = linkStatusSelector(check);
|
|
2119
|
+
const linkEvidence = viewport && viewport.link_statuses && viewport.link_statuses[selector];
|
|
2120
|
+
if (!linkEvidence || typeof linkEvidence !== "object" || Array.isArray(linkEvidence)) {
|
|
2121
|
+
return {
|
|
2122
|
+
viewport: viewport && viewport.name,
|
|
2123
|
+
selector,
|
|
2124
|
+
total_count: 0,
|
|
2125
|
+
ok_count: 0,
|
|
2126
|
+
failed_count: 1,
|
|
2127
|
+
failures: [{ code: "link_status_evidence_missing" }],
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter((result) => result && typeof result === "object" && !Array.isArray(result)) : [];
|
|
2131
|
+
const totalCount = typeof linkEvidence.total_count === "number" && Number.isFinite(linkEvidence.total_count) ? linkEvidence.total_count : results.length;
|
|
2132
|
+
const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
|
|
2133
|
+
const failures = results
|
|
2134
|
+
.filter((result) => !linkStatusResultOk(result, check))
|
|
2135
|
+
.map((result) => ({
|
|
2136
|
+
code: "link_status_failed",
|
|
2137
|
+
url: typeof result.url === "string" ? result.url : null,
|
|
2138
|
+
status: typeof result.status === "number" && Number.isFinite(result.status) ? result.status : null,
|
|
2139
|
+
method: typeof result.method === "string" ? result.method : null,
|
|
2140
|
+
error: typeof result.error === "string" ? result.error : null,
|
|
2141
|
+
bytes: typeof result.bytes === "number" && Number.isFinite(result.bytes)
|
|
2142
|
+
? result.bytes
|
|
2143
|
+
: typeof result.content_length === "number" && Number.isFinite(result.content_length)
|
|
2144
|
+
? result.content_length
|
|
2145
|
+
: null,
|
|
2146
|
+
}));
|
|
2147
|
+
if (typeof linkEvidence.error === "string" && linkEvidence.error.trim()) {
|
|
2148
|
+
failures.push({ code: "link_status_capture_failed", error: linkEvidence.error });
|
|
2149
|
+
}
|
|
2150
|
+
const recordedFailedCount = typeof linkEvidence.failed_count === "number" && Number.isFinite(linkEvidence.failed_count) ? linkEvidence.failed_count : 0;
|
|
2151
|
+
if (!failures.length && recordedFailedCount > 0) {
|
|
2152
|
+
failures.push({
|
|
2153
|
+
code: "link_status_recorded_failures",
|
|
2154
|
+
failed_count: recordedFailedCount,
|
|
2155
|
+
failures: Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [],
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
if (linkEvidence.truncated === true) {
|
|
2159
|
+
failures.push({
|
|
2160
|
+
code: "link_status_probe_truncated",
|
|
2161
|
+
discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
|
|
2162
|
+
max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
if (typeof check.expected_count === "number" && Number.isFinite(check.expected_count) && totalCount !== check.expected_count) {
|
|
2166
|
+
failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
|
|
2167
|
+
}
|
|
2168
|
+
if (typeof check.min_count === "number" && Number.isFinite(check.min_count) && totalCount < check.min_count) {
|
|
2169
|
+
failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
|
|
2170
|
+
}
|
|
2171
|
+
return {
|
|
2172
|
+
viewport: viewport && viewport.name,
|
|
2173
|
+
selector,
|
|
2174
|
+
total_count: totalCount,
|
|
2175
|
+
discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
|
|
2176
|
+
ok_count: typeof linkEvidence.ok_count === "number" ? linkEvidence.ok_count : okCount,
|
|
2177
|
+
failed_count: failures.length,
|
|
2178
|
+
truncated: linkEvidence.truncated === true,
|
|
2179
|
+
max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
|
|
2180
|
+
status_counts: linkEvidence.status_counts && typeof linkEvidence.status_counts === "object" && !Array.isArray(linkEvidence.status_counts) ? linkEvidence.status_counts : {},
|
|
2181
|
+
failures: failures.slice(0, 20),
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
1933
2184
|
function frameEvidenceForSelector(viewport, selector) {
|
|
1934
2185
|
const container = viewport.frames && viewport.frames[selector || ""];
|
|
1935
2186
|
if (!container || typeof container !== "object" || Array.isArray(container)) return [];
|
|
@@ -2583,6 +2834,29 @@ function assessProfile(profile, evidence) {
|
|
|
2583
2834
|
});
|
|
2584
2835
|
continue;
|
|
2585
2836
|
}
|
|
2837
|
+
if (check.type === "link_status" || check.type === "artifact_link_status") {
|
|
2838
|
+
const selector = linkStatusSelector(check);
|
|
2839
|
+
const summaries = checkViewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
|
|
2840
|
+
const failed = summaries.filter((summary) => summary.failed_count > 0);
|
|
2841
|
+
checks.push({
|
|
2842
|
+
type: check.type,
|
|
2843
|
+
label: check.label || check.type,
|
|
2844
|
+
status: failed.length ? "failed" : "passed",
|
|
2845
|
+
evidence: {
|
|
2846
|
+
selector,
|
|
2847
|
+
expected_count: check.expected_count ?? null,
|
|
2848
|
+
min_count: check.min_count ?? null,
|
|
2849
|
+
allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
|
|
2850
|
+
require_nonzero_bytes: check.require_nonzero_bytes === true,
|
|
2851
|
+
viewports: summaries,
|
|
2852
|
+
failures: failed.flatMap((summary) => Array.isArray(summary.failures)
|
|
2853
|
+
? summary.failures.map((failure) => ({ viewport: summary.viewport || null, failure }))
|
|
2854
|
+
: []),
|
|
2855
|
+
},
|
|
2856
|
+
message: failed.length ? "Link status failed in " + failed.length + " viewport(s)." : undefined,
|
|
2857
|
+
});
|
|
2858
|
+
continue;
|
|
2859
|
+
}
|
|
2586
2860
|
if (check.type === "route_inventory") {
|
|
2587
2861
|
const inventories = checkViewports
|
|
2588
2862
|
.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
|
|
@@ -3641,6 +3915,196 @@ async function selectorTextSequence(selector) {
|
|
|
3641
3915
|
};
|
|
3642
3916
|
}).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
|
|
3643
3917
|
}
|
|
3918
|
+
function linkProbeMaxLinks(check) {
|
|
3919
|
+
const value = Number(check.max_links || check.maxLinks || check.limit || 100);
|
|
3920
|
+
return Number.isInteger(value) && value > 0 ? Math.min(value, 500) : 100;
|
|
3921
|
+
}
|
|
3922
|
+
function linkProbeDedupe(check) {
|
|
3923
|
+
return check.dedupe !== false;
|
|
3924
|
+
}
|
|
3925
|
+
function linkProbeSameOriginOnly(check) {
|
|
3926
|
+
return check.same_origin_only === true;
|
|
3927
|
+
}
|
|
3928
|
+
async function collectLinkCandidates(selector) {
|
|
3929
|
+
return page.locator(selector).evaluateAll((elements) => {
|
|
3930
|
+
const compact = (value, limit) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit || 160);
|
|
3931
|
+
return elements.map((element, index) => {
|
|
3932
|
+
const tag = element && element.tagName ? element.tagName.toLowerCase() : "element";
|
|
3933
|
+
const href = element && typeof element.href === "string" ? element.href : "";
|
|
3934
|
+
const src = element && typeof element.src === "string" ? element.src : "";
|
|
3935
|
+
const currentSrc = element && typeof element.currentSrc === "string" ? element.currentSrc : "";
|
|
3936
|
+
const attrHref = element && typeof element.getAttribute === "function" ? element.getAttribute("href") || "" : "";
|
|
3937
|
+
const attrSrc = element && typeof element.getAttribute === "function" ? element.getAttribute("src") || "" : "";
|
|
3938
|
+
const attrPoster = element && typeof element.getAttribute === "function" ? element.getAttribute("poster") || "" : "";
|
|
3939
|
+
const raw = href || currentSrc || src || attrHref || attrSrc || attrPoster;
|
|
3940
|
+
if (!raw) return null;
|
|
3941
|
+
let url = "";
|
|
3942
|
+
try {
|
|
3943
|
+
url = new URL(raw, location.href).href;
|
|
3944
|
+
} catch {}
|
|
3945
|
+
if (!url) return null;
|
|
3946
|
+
return {
|
|
3947
|
+
index,
|
|
3948
|
+
tag,
|
|
3949
|
+
url,
|
|
3950
|
+
raw,
|
|
3951
|
+
text: compact(element.innerText || element.textContent || "", 160),
|
|
3952
|
+
alt: compact(element.getAttribute && element.getAttribute("alt"), 80),
|
|
3953
|
+
};
|
|
3954
|
+
}).filter(Boolean);
|
|
3955
|
+
}).catch((error) => ({ error: String(error && error.message ? error.message : error).slice(0, 500) }));
|
|
3956
|
+
}
|
|
3957
|
+
function linkProbeAllowed(status, check) {
|
|
3958
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
3959
|
+
const allowed = Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
|
|
3960
|
+
? check.allowed_statuses
|
|
3961
|
+
: typeof check.expected_status === "number" && Number.isFinite(check.expected_status)
|
|
3962
|
+
? [check.expected_status]
|
|
3963
|
+
: null;
|
|
3964
|
+
return allowed ? allowed.includes(status) : status >= 200 && status < 400;
|
|
3965
|
+
}
|
|
3966
|
+
function linkProbeResponseFields(response, method) {
|
|
3967
|
+
const contentLengthHeader = response.headers && typeof response.headers.get === "function" ? response.headers.get("content-length") : null;
|
|
3968
|
+
const contentLength = contentLengthHeader && /^\d+$/.test(contentLengthHeader) ? Number(contentLengthHeader) : null;
|
|
3969
|
+
return {
|
|
3970
|
+
method,
|
|
3971
|
+
status: response.status,
|
|
3972
|
+
redirected: Boolean(response.redirected),
|
|
3973
|
+
final_url: response.url || null,
|
|
3974
|
+
content_type: response.headers && typeof response.headers.get === "function" ? response.headers.get("content-type") : null,
|
|
3975
|
+
content_length: contentLength,
|
|
3976
|
+
};
|
|
3977
|
+
}
|
|
3978
|
+
async function probeLinkStatus(candidate, check) {
|
|
3979
|
+
const requireNonzeroBytes = check.require_nonzero_bytes === true;
|
|
3980
|
+
const allowGetFallback = check.allow_get_fallback !== false;
|
|
3981
|
+
const result = {
|
|
3982
|
+
url: candidate.url,
|
|
3983
|
+
tag: candidate.tag,
|
|
3984
|
+
text: candidate.text || null,
|
|
3985
|
+
status: null,
|
|
3986
|
+
method: null,
|
|
3987
|
+
ok: false,
|
|
3988
|
+
content_type: null,
|
|
3989
|
+
content_length: null,
|
|
3990
|
+
bytes: null,
|
|
3991
|
+
redirected: false,
|
|
3992
|
+
final_url: null,
|
|
3993
|
+
error: null,
|
|
3994
|
+
};
|
|
3995
|
+
const applyResponse = async (response, method, readBytes) => {
|
|
3996
|
+
Object.assign(result, linkProbeResponseFields(response, method));
|
|
3997
|
+
if (readBytes || requireNonzeroBytes) {
|
|
3998
|
+
try {
|
|
3999
|
+
const buffer = await response.arrayBuffer();
|
|
4000
|
+
result.bytes = buffer.byteLength;
|
|
4001
|
+
} catch (error) {
|
|
4002
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
result.ok = linkProbeAllowed(result.status, check)
|
|
4006
|
+
&& (!requireNonzeroBytes || (typeof result.bytes === "number" ? result.bytes > 0 : typeof result.content_length === "number" && result.content_length > 0))
|
|
4007
|
+
&& !result.error;
|
|
4008
|
+
};
|
|
4009
|
+
try {
|
|
4010
|
+
const response = await fetch(candidate.url, { method: "HEAD", redirect: "follow", cache: "no-store" });
|
|
4011
|
+
await applyResponse(response, "HEAD", false);
|
|
4012
|
+
if (result.ok || !allowGetFallback) return result;
|
|
4013
|
+
} catch (error) {
|
|
4014
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4015
|
+
if (!allowGetFallback) return result;
|
|
4016
|
+
}
|
|
4017
|
+
try {
|
|
4018
|
+
result.error = null;
|
|
4019
|
+
const response = await fetch(candidate.url, {
|
|
4020
|
+
method: "GET",
|
|
4021
|
+
redirect: "follow",
|
|
4022
|
+
cache: "no-store",
|
|
4023
|
+
headers: { Range: "bytes=0-0" },
|
|
4024
|
+
});
|
|
4025
|
+
await applyResponse(response, "GET", requireNonzeroBytes);
|
|
4026
|
+
return result;
|
|
4027
|
+
} catch (error) {
|
|
4028
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4029
|
+
result.ok = false;
|
|
4030
|
+
return result;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
async function collectLinkStatus(check) {
|
|
4034
|
+
const selector = linkStatusSelector(check);
|
|
4035
|
+
const candidateResult = await collectLinkCandidates(selector);
|
|
4036
|
+
if (candidateResult && candidateResult.error) {
|
|
4037
|
+
return {
|
|
4038
|
+
version: "riddle-proof.link-status.v1",
|
|
4039
|
+
selector,
|
|
4040
|
+
total_count: 0,
|
|
4041
|
+
discovered_count: 0,
|
|
4042
|
+
ok_count: 0,
|
|
4043
|
+
failed_count: 1,
|
|
4044
|
+
failures: [{ code: "link_status_capture_failed", error: candidateResult.error }],
|
|
4045
|
+
results: [],
|
|
4046
|
+
status_counts: {},
|
|
4047
|
+
error: candidateResult.error,
|
|
4048
|
+
};
|
|
4049
|
+
}
|
|
4050
|
+
const baseUrl = page.url() || targetUrl;
|
|
4051
|
+
let candidates = Array.isArray(candidateResult) ? candidateResult : [];
|
|
4052
|
+
if (linkProbeSameOriginOnly(check)) {
|
|
4053
|
+
const origin = new URL(baseUrl).origin;
|
|
4054
|
+
candidates = candidates.filter((candidate) => {
|
|
4055
|
+
try { return new URL(candidate.url).origin === origin; } catch { return false; }
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
const discoveredCount = candidates.length;
|
|
4059
|
+
if (linkProbeDedupe(check)) {
|
|
4060
|
+
const seen = new Set();
|
|
4061
|
+
candidates = candidates.filter((candidate) => {
|
|
4062
|
+
if (seen.has(candidate.url)) return false;
|
|
4063
|
+
seen.add(candidate.url);
|
|
4064
|
+
return true;
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
const maxLinks = linkProbeMaxLinks(check);
|
|
4068
|
+
const selected = candidates.slice(0, maxLinks);
|
|
4069
|
+
const results = [];
|
|
4070
|
+
for (const candidate of selected) {
|
|
4071
|
+
results.push(await probeLinkStatus(candidate, check));
|
|
4072
|
+
}
|
|
4073
|
+
const failures = results.filter((result) => !result.ok).map((result) => ({
|
|
4074
|
+
code: "link_status_failed",
|
|
4075
|
+
url: result.url,
|
|
4076
|
+
status: result.status,
|
|
4077
|
+
method: result.method,
|
|
4078
|
+
error: result.error,
|
|
4079
|
+
bytes: result.bytes == null ? result.content_length : result.bytes,
|
|
4080
|
+
}));
|
|
4081
|
+
const statusCounts = {};
|
|
4082
|
+
for (const result of results) {
|
|
4083
|
+
const key = result.status == null ? "error" : String(result.status);
|
|
4084
|
+
statusCounts[key] = (statusCounts[key] || 0) + 1;
|
|
4085
|
+
}
|
|
4086
|
+
return {
|
|
4087
|
+
version: "riddle-proof.link-status.v1",
|
|
4088
|
+
selector,
|
|
4089
|
+
max_links: maxLinks,
|
|
4090
|
+
same_origin_only: linkProbeSameOriginOnly(check),
|
|
4091
|
+
dedupe: linkProbeDedupe(check),
|
|
4092
|
+
require_nonzero_bytes: check.require_nonzero_bytes === true,
|
|
4093
|
+
allowed_statuses: Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
|
|
4094
|
+
? check.allowed_statuses
|
|
4095
|
+
: typeof check.expected_status === "number"
|
|
4096
|
+
? [check.expected_status]
|
|
4097
|
+
: ["2xx", "3xx"],
|
|
4098
|
+
discovered_count: discoveredCount,
|
|
4099
|
+
total_count: selected.length,
|
|
4100
|
+
truncated: candidates.length > selected.length,
|
|
4101
|
+
ok_count: results.filter((result) => result.ok).length,
|
|
4102
|
+
failed_count: failures.length,
|
|
4103
|
+
status_counts: statusCounts,
|
|
4104
|
+
failures: failures.slice(0, 20),
|
|
4105
|
+
results,
|
|
4106
|
+
};
|
|
4107
|
+
}
|
|
3644
4108
|
async function frameEvidence(selector) {
|
|
3645
4109
|
const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
|
|
3646
4110
|
let handles = [];
|
|
@@ -4160,6 +4624,7 @@ async function captureViewport(viewport) {
|
|
|
4160
4624
|
const frames = {};
|
|
4161
4625
|
const text_sequences = {};
|
|
4162
4626
|
const text_matches = {};
|
|
4627
|
+
const link_statuses = {};
|
|
4163
4628
|
for (const check of profile.checks || []) {
|
|
4164
4629
|
if (
|
|
4165
4630
|
(
|
|
@@ -4184,6 +4649,10 @@ async function captureViewport(viewport) {
|
|
|
4184
4649
|
selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
|
|
4185
4650
|
frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
|
|
4186
4651
|
}
|
|
4652
|
+
if (check.type === "link_status" || check.type === "artifact_link_status") {
|
|
4653
|
+
const selector = linkStatusSelector(check);
|
|
4654
|
+
link_statuses[selector] = link_statuses[selector] || await collectLinkStatus(check);
|
|
4655
|
+
}
|
|
4187
4656
|
}
|
|
4188
4657
|
const screenshotLabel = profileSlug + "-" + viewport.name;
|
|
4189
4658
|
try {
|
|
@@ -4247,6 +4716,7 @@ async function captureViewport(viewport) {
|
|
|
4247
4716
|
frames,
|
|
4248
4717
|
text_sequences,
|
|
4249
4718
|
text_matches,
|
|
4719
|
+
link_statuses,
|
|
4250
4720
|
route_inventory: routeInventory,
|
|
4251
4721
|
setup_action_results: setupActionResults,
|
|
4252
4722
|
screenshot_label: screenshotLabel,
|
|
@@ -4295,6 +4765,18 @@ function buildProfileEvidence(currentViewports) {
|
|
|
4295
4765
|
),
|
|
4296
4766
|
})),
|
|
4297
4767
|
})),
|
|
4768
|
+
link_status: currentViewports
|
|
4769
|
+
.filter((viewport) => viewport.link_statuses)
|
|
4770
|
+
.map((viewport) => ({
|
|
4771
|
+
viewport: viewport.name,
|
|
4772
|
+
selectors: Object.entries(viewport.link_statuses || {}).map(([selector, statusSet]) => ({
|
|
4773
|
+
selector,
|
|
4774
|
+
total_count: statusSet && statusSet.total_count,
|
|
4775
|
+
ok_count: statusSet && statusSet.ok_count,
|
|
4776
|
+
failed_count: statusSet && statusSet.failed_count,
|
|
4777
|
+
truncated: statusSet && statusSet.truncated === true,
|
|
4778
|
+
})),
|
|
4779
|
+
})),
|
|
4298
4780
|
route_inventory: currentViewports
|
|
4299
4781
|
.filter((viewport) => viewport.route_inventory)
|
|
4300
4782
|
.map((viewport) => ({
|