@riddledc/riddle-proof 0.7.89 → 0.7.91
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 +23 -0
- package/dist/{chunk-CEBHV23V.js → chunk-2SGLFPFX.js} +517 -6
- package/dist/cli.cjs +548 -6
- package/dist/cli.js +32 -1
- package/dist/index.cjs +517 -6
- package/dist/index.js +1 -1
- package/dist/profile.cjs +517 -6
- package/dist/profile.d.cts +9 -1
- package/dist/profile.d.ts +9 -1
- package/dist/profile.js +1 -1
- package/dist/proof-run-core.d.cts +1 -1
- package/dist/proof-run-core.d.ts +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
|
@@ -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 }))
|
|
@@ -2823,6 +3097,10 @@ function textMatches(sample, check) {
|
|
|
2823
3097
|
}
|
|
2824
3098
|
return String(sample || "").includes(check.text || "");
|
|
2825
3099
|
}
|
|
3100
|
+
function profileCheckAppliesToViewport(check, viewport) {
|
|
3101
|
+
if (!Array.isArray(check.viewports) || !check.viewports.length) return true;
|
|
3102
|
+
return Boolean(viewport && viewport.name && check.viewports.includes(viewport.name));
|
|
3103
|
+
}
|
|
2826
3104
|
function setupActionType(action) {
|
|
2827
3105
|
return String(action && action.type ? action.type : "").replace(/-/g, "_");
|
|
2828
3106
|
}
|
|
@@ -2840,6 +3118,30 @@ function setupTextMatches(sample, action) {
|
|
|
2840
3118
|
}
|
|
2841
3119
|
return String(sample || "").includes(action.text || "");
|
|
2842
3120
|
}
|
|
3121
|
+
async function waitForAnyVisibleSelector(context, selector, timeout) {
|
|
3122
|
+
const deadline = Date.now() + setupNumber(timeout, 15000);
|
|
3123
|
+
let lastReason = "selector_not_found";
|
|
3124
|
+
while (Date.now() <= deadline) {
|
|
3125
|
+
try {
|
|
3126
|
+
const locator = context.locator(selector);
|
|
3127
|
+
const count = await locator.count();
|
|
3128
|
+
if (!count) {
|
|
3129
|
+
lastReason = "selector_not_found";
|
|
3130
|
+
} else {
|
|
3131
|
+
lastReason = "no_visible_match";
|
|
3132
|
+
for (let index = 0; index < count; index += 1) {
|
|
3133
|
+
if (await locator.nth(index).isVisible().catch(() => false)) {
|
|
3134
|
+
return { ok: true, count, index };
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
} catch (error) {
|
|
3139
|
+
lastReason = String(error && error.message ? error.message : error).slice(0, 500);
|
|
3140
|
+
}
|
|
3141
|
+
await page.waitForTimeout(200);
|
|
3142
|
+
}
|
|
3143
|
+
throw new Error("No visible match for selector " + selector + ": " + lastReason);
|
|
3144
|
+
}
|
|
2843
3145
|
function setupHasOwn(action, key) {
|
|
2844
3146
|
return Boolean(action) && Object.keys(action).includes(key);
|
|
2845
3147
|
}
|
|
@@ -3173,7 +3475,7 @@ async function executeSetupAction(action, ordinal, viewport) {
|
|
|
3173
3475
|
if (type === "wait_for_selector") {
|
|
3174
3476
|
const scope = await setupActionScope(action, timeout);
|
|
3175
3477
|
if (!scope.ok) return setupScopeFailure(base, scope);
|
|
3176
|
-
await scope.context
|
|
3478
|
+
await waitForAnyVisibleSelector(scope.context, action.selector, timeout);
|
|
3177
3479
|
return { ...base, ...setupScopeEvidence(scope), ok: true, timeout_ms: timeout };
|
|
3178
3480
|
}
|
|
3179
3481
|
if (type === "drag") {
|
|
@@ -3641,6 +3943,196 @@ async function selectorTextSequence(selector) {
|
|
|
3641
3943
|
};
|
|
3642
3944
|
}).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
|
|
3643
3945
|
}
|
|
3946
|
+
function linkProbeMaxLinks(check) {
|
|
3947
|
+
const value = Number(check.max_links || check.maxLinks || check.limit || 100);
|
|
3948
|
+
return Number.isInteger(value) && value > 0 ? Math.min(value, 500) : 100;
|
|
3949
|
+
}
|
|
3950
|
+
function linkProbeDedupe(check) {
|
|
3951
|
+
return check.dedupe !== false;
|
|
3952
|
+
}
|
|
3953
|
+
function linkProbeSameOriginOnly(check) {
|
|
3954
|
+
return check.same_origin_only === true;
|
|
3955
|
+
}
|
|
3956
|
+
async function collectLinkCandidates(selector) {
|
|
3957
|
+
return page.locator(selector).evaluateAll((elements) => {
|
|
3958
|
+
const compact = (value, limit) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit || 160);
|
|
3959
|
+
return elements.map((element, index) => {
|
|
3960
|
+
const tag = element && element.tagName ? element.tagName.toLowerCase() : "element";
|
|
3961
|
+
const href = element && typeof element.href === "string" ? element.href : "";
|
|
3962
|
+
const src = element && typeof element.src === "string" ? element.src : "";
|
|
3963
|
+
const currentSrc = element && typeof element.currentSrc === "string" ? element.currentSrc : "";
|
|
3964
|
+
const attrHref = element && typeof element.getAttribute === "function" ? element.getAttribute("href") || "" : "";
|
|
3965
|
+
const attrSrc = element && typeof element.getAttribute === "function" ? element.getAttribute("src") || "" : "";
|
|
3966
|
+
const attrPoster = element && typeof element.getAttribute === "function" ? element.getAttribute("poster") || "" : "";
|
|
3967
|
+
const raw = href || currentSrc || src || attrHref || attrSrc || attrPoster;
|
|
3968
|
+
if (!raw) return null;
|
|
3969
|
+
let url = "";
|
|
3970
|
+
try {
|
|
3971
|
+
url = new URL(raw, location.href).href;
|
|
3972
|
+
} catch {}
|
|
3973
|
+
if (!url) return null;
|
|
3974
|
+
return {
|
|
3975
|
+
index,
|
|
3976
|
+
tag,
|
|
3977
|
+
url,
|
|
3978
|
+
raw,
|
|
3979
|
+
text: compact(element.innerText || element.textContent || "", 160),
|
|
3980
|
+
alt: compact(element.getAttribute && element.getAttribute("alt"), 80),
|
|
3981
|
+
};
|
|
3982
|
+
}).filter(Boolean);
|
|
3983
|
+
}).catch((error) => ({ error: String(error && error.message ? error.message : error).slice(0, 500) }));
|
|
3984
|
+
}
|
|
3985
|
+
function linkProbeAllowed(status, check) {
|
|
3986
|
+
if (typeof status !== "number" || !Number.isFinite(status)) return false;
|
|
3987
|
+
const allowed = Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
|
|
3988
|
+
? check.allowed_statuses
|
|
3989
|
+
: typeof check.expected_status === "number" && Number.isFinite(check.expected_status)
|
|
3990
|
+
? [check.expected_status]
|
|
3991
|
+
: null;
|
|
3992
|
+
return allowed ? allowed.includes(status) : status >= 200 && status < 400;
|
|
3993
|
+
}
|
|
3994
|
+
function linkProbeResponseFields(response, method) {
|
|
3995
|
+
const contentLengthHeader = response.headers && typeof response.headers.get === "function" ? response.headers.get("content-length") : null;
|
|
3996
|
+
const contentLength = contentLengthHeader && /^\d+$/.test(contentLengthHeader) ? Number(contentLengthHeader) : null;
|
|
3997
|
+
return {
|
|
3998
|
+
method,
|
|
3999
|
+
status: response.status,
|
|
4000
|
+
redirected: Boolean(response.redirected),
|
|
4001
|
+
final_url: response.url || null,
|
|
4002
|
+
content_type: response.headers && typeof response.headers.get === "function" ? response.headers.get("content-type") : null,
|
|
4003
|
+
content_length: contentLength,
|
|
4004
|
+
};
|
|
4005
|
+
}
|
|
4006
|
+
async function probeLinkStatus(candidate, check) {
|
|
4007
|
+
const requireNonzeroBytes = check.require_nonzero_bytes === true;
|
|
4008
|
+
const allowGetFallback = check.allow_get_fallback !== false;
|
|
4009
|
+
const result = {
|
|
4010
|
+
url: candidate.url,
|
|
4011
|
+
tag: candidate.tag,
|
|
4012
|
+
text: candidate.text || null,
|
|
4013
|
+
status: null,
|
|
4014
|
+
method: null,
|
|
4015
|
+
ok: false,
|
|
4016
|
+
content_type: null,
|
|
4017
|
+
content_length: null,
|
|
4018
|
+
bytes: null,
|
|
4019
|
+
redirected: false,
|
|
4020
|
+
final_url: null,
|
|
4021
|
+
error: null,
|
|
4022
|
+
};
|
|
4023
|
+
const applyResponse = async (response, method, readBytes) => {
|
|
4024
|
+
Object.assign(result, linkProbeResponseFields(response, method));
|
|
4025
|
+
if (readBytes || requireNonzeroBytes) {
|
|
4026
|
+
try {
|
|
4027
|
+
const buffer = await response.arrayBuffer();
|
|
4028
|
+
result.bytes = buffer.byteLength;
|
|
4029
|
+
} catch (error) {
|
|
4030
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
result.ok = linkProbeAllowed(result.status, check)
|
|
4034
|
+
&& (!requireNonzeroBytes || (typeof result.bytes === "number" ? result.bytes > 0 : typeof result.content_length === "number" && result.content_length > 0))
|
|
4035
|
+
&& !result.error;
|
|
4036
|
+
};
|
|
4037
|
+
try {
|
|
4038
|
+
const response = await fetch(candidate.url, { method: "HEAD", redirect: "follow", cache: "no-store" });
|
|
4039
|
+
await applyResponse(response, "HEAD", false);
|
|
4040
|
+
if (result.ok || !allowGetFallback) return result;
|
|
4041
|
+
} catch (error) {
|
|
4042
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4043
|
+
if (!allowGetFallback) return result;
|
|
4044
|
+
}
|
|
4045
|
+
try {
|
|
4046
|
+
result.error = null;
|
|
4047
|
+
const response = await fetch(candidate.url, {
|
|
4048
|
+
method: "GET",
|
|
4049
|
+
redirect: "follow",
|
|
4050
|
+
cache: "no-store",
|
|
4051
|
+
headers: { Range: "bytes=0-0" },
|
|
4052
|
+
});
|
|
4053
|
+
await applyResponse(response, "GET", requireNonzeroBytes);
|
|
4054
|
+
return result;
|
|
4055
|
+
} catch (error) {
|
|
4056
|
+
result.error = String(error && error.message ? error.message : error).slice(0, 500);
|
|
4057
|
+
result.ok = false;
|
|
4058
|
+
return result;
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
async function collectLinkStatus(check) {
|
|
4062
|
+
const selector = linkStatusSelector(check);
|
|
4063
|
+
const candidateResult = await collectLinkCandidates(selector);
|
|
4064
|
+
if (candidateResult && candidateResult.error) {
|
|
4065
|
+
return {
|
|
4066
|
+
version: "riddle-proof.link-status.v1",
|
|
4067
|
+
selector,
|
|
4068
|
+
total_count: 0,
|
|
4069
|
+
discovered_count: 0,
|
|
4070
|
+
ok_count: 0,
|
|
4071
|
+
failed_count: 1,
|
|
4072
|
+
failures: [{ code: "link_status_capture_failed", error: candidateResult.error }],
|
|
4073
|
+
results: [],
|
|
4074
|
+
status_counts: {},
|
|
4075
|
+
error: candidateResult.error,
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
const baseUrl = page.url() || targetUrl;
|
|
4079
|
+
let candidates = Array.isArray(candidateResult) ? candidateResult : [];
|
|
4080
|
+
if (linkProbeSameOriginOnly(check)) {
|
|
4081
|
+
const origin = new URL(baseUrl).origin;
|
|
4082
|
+
candidates = candidates.filter((candidate) => {
|
|
4083
|
+
try { return new URL(candidate.url).origin === origin; } catch { return false; }
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4086
|
+
const discoveredCount = candidates.length;
|
|
4087
|
+
if (linkProbeDedupe(check)) {
|
|
4088
|
+
const seen = new Set();
|
|
4089
|
+
candidates = candidates.filter((candidate) => {
|
|
4090
|
+
if (seen.has(candidate.url)) return false;
|
|
4091
|
+
seen.add(candidate.url);
|
|
4092
|
+
return true;
|
|
4093
|
+
});
|
|
4094
|
+
}
|
|
4095
|
+
const maxLinks = linkProbeMaxLinks(check);
|
|
4096
|
+
const selected = candidates.slice(0, maxLinks);
|
|
4097
|
+
const results = [];
|
|
4098
|
+
for (const candidate of selected) {
|
|
4099
|
+
results.push(await probeLinkStatus(candidate, check));
|
|
4100
|
+
}
|
|
4101
|
+
const failures = results.filter((result) => !result.ok).map((result) => ({
|
|
4102
|
+
code: "link_status_failed",
|
|
4103
|
+
url: result.url,
|
|
4104
|
+
status: result.status,
|
|
4105
|
+
method: result.method,
|
|
4106
|
+
error: result.error,
|
|
4107
|
+
bytes: result.bytes == null ? result.content_length : result.bytes,
|
|
4108
|
+
}));
|
|
4109
|
+
const statusCounts = {};
|
|
4110
|
+
for (const result of results) {
|
|
4111
|
+
const key = result.status == null ? "error" : String(result.status);
|
|
4112
|
+
statusCounts[key] = (statusCounts[key] || 0) + 1;
|
|
4113
|
+
}
|
|
4114
|
+
return {
|
|
4115
|
+
version: "riddle-proof.link-status.v1",
|
|
4116
|
+
selector,
|
|
4117
|
+
max_links: maxLinks,
|
|
4118
|
+
same_origin_only: linkProbeSameOriginOnly(check),
|
|
4119
|
+
dedupe: linkProbeDedupe(check),
|
|
4120
|
+
require_nonzero_bytes: check.require_nonzero_bytes === true,
|
|
4121
|
+
allowed_statuses: Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
|
|
4122
|
+
? check.allowed_statuses
|
|
4123
|
+
: typeof check.expected_status === "number"
|
|
4124
|
+
? [check.expected_status]
|
|
4125
|
+
: ["2xx", "3xx"],
|
|
4126
|
+
discovered_count: discoveredCount,
|
|
4127
|
+
total_count: selected.length,
|
|
4128
|
+
truncated: candidates.length > selected.length,
|
|
4129
|
+
ok_count: results.filter((result) => result.ok).length,
|
|
4130
|
+
failed_count: failures.length,
|
|
4131
|
+
status_counts: statusCounts,
|
|
4132
|
+
failures: failures.slice(0, 20),
|
|
4133
|
+
results,
|
|
4134
|
+
};
|
|
4135
|
+
}
|
|
3644
4136
|
async function frameEvidence(selector) {
|
|
3645
4137
|
const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
|
|
3646
4138
|
let handles = [];
|
|
@@ -3999,7 +4491,7 @@ async function collectRouteInventory(check, viewport) {
|
|
|
3999
4491
|
try {
|
|
4000
4492
|
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 90000 });
|
|
4001
4493
|
if (profile.target.wait_for_selector) {
|
|
4002
|
-
await page
|
|
4494
|
+
await waitForAnyVisibleSelector(page, profile.target.wait_for_selector, 15000).catch(() => {});
|
|
4003
4495
|
}
|
|
4004
4496
|
const index = await findInventoryLinkIndex(check, expectedRoute.path);
|
|
4005
4497
|
if (index < 0) {
|
|
@@ -4058,7 +4550,7 @@ async function captureViewport(viewport) {
|
|
|
4058
4550
|
}
|
|
4059
4551
|
if (!navigationError && profile.target.wait_for_selector) {
|
|
4060
4552
|
try {
|
|
4061
|
-
await page
|
|
4553
|
+
await waitForAnyVisibleSelector(page, profile.target.wait_for_selector, 15000);
|
|
4062
4554
|
} catch (error) {
|
|
4063
4555
|
waitError = String(error && error.message ? error.message : error).slice(0, 1000);
|
|
4064
4556
|
}
|
|
@@ -4160,7 +4652,9 @@ async function captureViewport(viewport) {
|
|
|
4160
4652
|
const frames = {};
|
|
4161
4653
|
const text_sequences = {};
|
|
4162
4654
|
const text_matches = {};
|
|
4655
|
+
const link_statuses = {};
|
|
4163
4656
|
for (const check of profile.checks || []) {
|
|
4657
|
+
if (!profileCheckAppliesToViewport(check, viewport)) continue;
|
|
4164
4658
|
if (
|
|
4165
4659
|
(
|
|
4166
4660
|
check.type === "selector_visible"
|
|
@@ -4184,6 +4678,10 @@ async function captureViewport(viewport) {
|
|
|
4184
4678
|
selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
|
|
4185
4679
|
frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
|
|
4186
4680
|
}
|
|
4681
|
+
if (check.type === "link_status" || check.type === "artifact_link_status") {
|
|
4682
|
+
const selector = linkStatusSelector(check);
|
|
4683
|
+
link_statuses[selector] = link_statuses[selector] || await collectLinkStatus(check);
|
|
4684
|
+
}
|
|
4187
4685
|
}
|
|
4188
4686
|
const screenshotLabel = profileSlug + "-" + viewport.name;
|
|
4189
4687
|
try {
|
|
@@ -4192,7 +4690,7 @@ async function captureViewport(viewport) {
|
|
|
4192
4690
|
pageErrors.push({ message: "saveScreenshot failed: " + String(error && error.message ? error.message : error).slice(0, 500) });
|
|
4193
4691
|
}
|
|
4194
4692
|
let routeInventory;
|
|
4195
|
-
const routeInventoryCheck = (profile.checks || []).find((check) => check.type === "route_inventory");
|
|
4693
|
+
const routeInventoryCheck = (profile.checks || []).find((check) => check.type === "route_inventory" && profileCheckAppliesToViewport(check, viewport));
|
|
4196
4694
|
const firstViewportName = (profile.target.viewports || [])[0] && (profile.target.viewports || [])[0].name;
|
|
4197
4695
|
if (routeInventoryCheck && (routeInventoryCheck.run_all_viewports || !firstViewportName || viewport.name === firstViewportName)) {
|
|
4198
4696
|
try {
|
|
@@ -4247,6 +4745,7 @@ async function captureViewport(viewport) {
|
|
|
4247
4745
|
frames,
|
|
4248
4746
|
text_sequences,
|
|
4249
4747
|
text_matches,
|
|
4748
|
+
link_statuses,
|
|
4250
4749
|
route_inventory: routeInventory,
|
|
4251
4750
|
setup_action_results: setupActionResults,
|
|
4252
4751
|
screenshot_label: screenshotLabel,
|
|
@@ -4295,6 +4794,18 @@ function buildProfileEvidence(currentViewports) {
|
|
|
4295
4794
|
),
|
|
4296
4795
|
})),
|
|
4297
4796
|
})),
|
|
4797
|
+
link_status: currentViewports
|
|
4798
|
+
.filter((viewport) => viewport.link_statuses && Object.keys(viewport.link_statuses).length)
|
|
4799
|
+
.map((viewport) => ({
|
|
4800
|
+
viewport: viewport.name,
|
|
4801
|
+
selectors: Object.entries(viewport.link_statuses || {}).map(([selector, statusSet]) => ({
|
|
4802
|
+
selector,
|
|
4803
|
+
total_count: statusSet && statusSet.total_count,
|
|
4804
|
+
ok_count: statusSet && statusSet.ok_count,
|
|
4805
|
+
failed_count: statusSet && statusSet.failed_count,
|
|
4806
|
+
truncated: statusSet && statusSet.truncated === true,
|
|
4807
|
+
})),
|
|
4808
|
+
})),
|
|
4298
4809
|
route_inventory: currentViewports
|
|
4299
4810
|
.filter((viewport) => viewport.route_inventory)
|
|
4300
4811
|
.map((viewport) => ({
|