@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.
@@ -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
- if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
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: numberValue(input.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.waitForSelector(action.selector, { state: "visible", timeout });
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.waitForSelector(profile.target.wait_for_selector, { state: "visible", timeout: 15000 }).catch(() => {});
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.waitForSelector(profile.target.wait_for_selector, { state: "visible", timeout: 15000 });
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) => ({