@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/dist/profile.cjs CHANGED
@@ -70,6 +70,8 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
70
70
  "frame_no_horizontal_overflow",
71
71
  "text_visible",
72
72
  "text_absent",
73
+ "link_status",
74
+ "artifact_link_status",
73
75
  "route_inventory",
74
76
  "no_horizontal_overflow",
75
77
  "no_mobile_horizontal_overflow",
@@ -734,6 +736,29 @@ function normalizeStringList(value, label) {
734
736
  if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
735
737
  return values;
736
738
  }
739
+ function normalizeHttpStatus(value, label) {
740
+ if (value === void 0) return void 0;
741
+ const status = numberValue(value);
742
+ if (status === void 0 || !Number.isInteger(status) || status < 100 || status > 599) {
743
+ throw new Error(`${label} must be an HTTP status code.`);
744
+ }
745
+ return status;
746
+ }
747
+ function normalizeHttpStatuses(value, label) {
748
+ if (value === void 0) return void 0;
749
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
750
+ if (!value.length) throw new Error(`${label} must not be empty.`);
751
+ const statuses = value.map((item, index) => normalizeHttpStatus(item, `${label}[${index}]`));
752
+ return Array.from(new Set(statuses));
753
+ }
754
+ function normalizePositiveInteger(value, label, max) {
755
+ if (value === void 0) return void 0;
756
+ const number = numberValue(value);
757
+ if (number === void 0 || !Number.isInteger(number) || number < 1 || max !== void 0 && number > max) {
758
+ throw new Error(`${label} must be an integer from 1 to ${max ?? "unbounded"}.`);
759
+ }
760
+ return number;
761
+ }
737
762
  function validateRegexPatterns(patterns, label) {
738
763
  for (const pattern of patterns || []) {
739
764
  try {
@@ -777,7 +802,8 @@ function normalizeCheck(input, index) {
777
802
  if (type === "url_search_param_equals" && expectedValue === void 0) {
778
803
  throw new Error(`checks[${index}] url_search_param_equals requires expected_value.`);
779
804
  }
780
- if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
805
+ const minCount = numberValue(input.min_count) ?? numberValue(input.minCount);
806
+ if (type === "selector_count_at_least" && minCount === void 0) {
781
807
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
782
808
  }
783
809
  const expectedCount = numberValue(input.expected_count) ?? numberValue(input.expectedCount) ?? numberValue(input.count);
@@ -793,6 +819,21 @@ function normalizeCheck(input, index) {
793
819
  if (type === "route_inventory" && !expectedRoutes?.length) {
794
820
  throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
795
821
  }
822
+ const isLinkStatusCheck = type === "link_status" || type === "artifact_link_status";
823
+ const expectedStatus = isLinkStatusCheck ? normalizeHttpStatus(input.expected_status ?? input.expectedStatus ?? input.status, `checks[${index}] expected_status`) : void 0;
824
+ const allowedStatuses = isLinkStatusCheck ? normalizeHttpStatuses(
825
+ input.allowed_statuses ?? input.allowedStatuses ?? input.expected_statuses ?? input.expectedStatuses,
826
+ `checks[${index}] allowed_statuses`
827
+ ) : void 0;
828
+ const maxLinks = isLinkStatusCheck ? normalizePositiveInteger(input.max_links ?? input.maxLinks ?? input.limit, `checks[${index}] max_links`, 500) : void 0;
829
+ if (isLinkStatusCheck) {
830
+ if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
831
+ throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
832
+ }
833
+ if (expectedCount !== void 0 && (!Number.isInteger(expectedCount) || expectedCount < 0)) {
834
+ throw new Error(`checks[${index}] ${type} expected_count must be a non-negative integer.`);
835
+ }
836
+ }
796
837
  return {
797
838
  type,
798
839
  label: stringValue(input.label),
@@ -818,8 +859,15 @@ function normalizeCheck(input, index) {
818
859
  allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
819
860
  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`),
820
861
  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`),
821
- min_count: numberValue(input.min_count),
862
+ min_count: minCount,
822
863
  expected_count: expectedCount,
864
+ expected_status: expectedStatus,
865
+ allowed_statuses: allowedStatuses,
866
+ max_links: maxLinks,
867
+ same_origin_only: isLinkStatusCheck ? input.same_origin_only === true || input.sameOriginOnly === true : void 0,
868
+ dedupe: isLinkStatusCheck ? input.dedupe === false ? false : true : void 0,
869
+ require_nonzero_bytes: isLinkStatusCheck ? input.require_nonzero_bytes === true || input.requireNonzeroBytes === true : void 0,
870
+ allow_get_fallback: isLinkStatusCheck ? input.allow_get_fallback === false || input.allowGetFallback === false ? false : true : void 0,
823
871
  max_overflow_px: numberValue(input.max_overflow_px),
824
872
  timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
825
873
  run_direct_routes: input.run_direct_routes === false || input.runDirectRoutes === false ? false : true,
@@ -912,6 +960,97 @@ function checkLabel(check) {
912
960
  function selectorKey(check) {
913
961
  return check.selector || "";
914
962
  }
963
+ function linkStatusSelector(check) {
964
+ return check.selector || check.link_selector || "a[href]";
965
+ }
966
+ function linkStatusAllowedStatuses(check) {
967
+ if (check.allowed_statuses?.length) return check.allowed_statuses;
968
+ if (check.expected_status !== void 0) return [check.expected_status];
969
+ return void 0;
970
+ }
971
+ function linkStatusIsAllowed(status, check) {
972
+ if (status === void 0) return false;
973
+ const allowed = linkStatusAllowedStatuses(check);
974
+ return allowed?.length ? allowed.includes(status) : status >= 200 && status < 400;
975
+ }
976
+ function linkStatusResultOk(result, check) {
977
+ const status = numberValue(result.status);
978
+ if (!linkStatusIsAllowed(status, check)) return false;
979
+ if (stringValue(result.error)) return false;
980
+ if (result.ok === false) return false;
981
+ if (check.require_nonzero_bytes) {
982
+ const bytes = numberValue(result.bytes);
983
+ const contentLength = numberValue(result.content_length);
984
+ return bytes !== void 0 && bytes > 0 || contentLength !== void 0 && contentLength > 0;
985
+ }
986
+ return true;
987
+ }
988
+ function linkStatusEvidenceForCheck(viewport, check) {
989
+ const evidence = viewport.link_statuses?.[linkStatusSelector(check)];
990
+ return isRecord(evidence) ? evidence : void 0;
991
+ }
992
+ function summarizeLinkStatusEvidence(viewport, check) {
993
+ const linkEvidence = linkStatusEvidenceForCheck(viewport, check);
994
+ if (!linkEvidence) {
995
+ return {
996
+ viewport: viewport.name,
997
+ selector: linkStatusSelector(check),
998
+ total_count: 0,
999
+ ok_count: 0,
1000
+ failed_count: 1,
1001
+ failures: [{ code: "link_status_evidence_missing" }]
1002
+ };
1003
+ }
1004
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter(isRecord) : [];
1005
+ const totalCount = numberValue(linkEvidence.total_count) ?? results.length;
1006
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
1007
+ const failures = results.filter((result) => !linkStatusResultOk(result, check)).map((result) => ({
1008
+ code: "link_status_failed",
1009
+ url: stringValue(result.url) ?? null,
1010
+ status: numberValue(result.status) ?? null,
1011
+ method: stringValue(result.method) ?? null,
1012
+ error: stringValue(result.error) ?? null,
1013
+ bytes: numberValue(result.bytes) ?? numberValue(result.content_length) ?? null
1014
+ }));
1015
+ if (stringValue(linkEvidence.error)) {
1016
+ failures.push({ code: "link_status_capture_failed", error: stringValue(linkEvidence.error) ?? "" });
1017
+ }
1018
+ const recordedFailedCount = numberValue(linkEvidence.failed_count) ?? 0;
1019
+ if (!failures.length && recordedFailedCount > 0) {
1020
+ const recordedFailures = Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [];
1021
+ failures.push({
1022
+ code: "link_status_recorded_failures",
1023
+ failed_count: recordedFailedCount,
1024
+ failures: toJsonValue(recordedFailures)
1025
+ });
1026
+ }
1027
+ if (linkEvidence.truncated === true) {
1028
+ failures.push({
1029
+ code: "link_status_probe_truncated",
1030
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
1031
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100
1032
+ });
1033
+ }
1034
+ if (check.expected_count !== void 0 && totalCount !== check.expected_count) {
1035
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
1036
+ }
1037
+ if (check.min_count !== void 0 && totalCount < check.min_count) {
1038
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
1039
+ }
1040
+ const statusCounts = isRecord(linkEvidence.status_counts) ? linkEvidence.status_counts : {};
1041
+ return {
1042
+ viewport: viewport.name,
1043
+ selector: linkStatusSelector(check),
1044
+ total_count: totalCount,
1045
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
1046
+ ok_count: numberValue(linkEvidence.ok_count) ?? okCount,
1047
+ failed_count: failures.length,
1048
+ truncated: linkEvidence.truncated === true,
1049
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100,
1050
+ status_counts: statusCounts,
1051
+ failures: failures.slice(0, 20)
1052
+ };
1053
+ }
915
1054
  function textKey(check) {
916
1055
  return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
917
1056
  }
@@ -1391,6 +1530,26 @@ function assessCheckFromEvidence(check, evidence) {
1391
1530
  message: failed ? `Text assertion failed in ${failed} viewport(s).` : void 0
1392
1531
  };
1393
1532
  }
1533
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
1534
+ const selector = linkStatusSelector(check);
1535
+ const summaries = viewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
1536
+ const failed = summaries.filter((summary) => (numberValue(summary.failed_count) ?? 0) > 0);
1537
+ return {
1538
+ type: check.type,
1539
+ label: checkLabel(check),
1540
+ status: failed.length ? "failed" : "passed",
1541
+ evidence: {
1542
+ selector,
1543
+ expected_count: check.expected_count ?? null,
1544
+ min_count: check.min_count ?? null,
1545
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
1546
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
1547
+ viewports: summaries.map((summary) => toJsonValue(summary)),
1548
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue(summary.viewport) ?? null, failure })) : [])
1549
+ },
1550
+ message: failed.length ? `Link status failed in ${failed.length} viewport(s).` : void 0
1551
+ };
1552
+ }
1394
1553
  if (check.type === "route_inventory") {
1395
1554
  const inventories = viewports.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory })).filter((item) => isRecord(item.inventory));
1396
1555
  if (!inventories.length) {
@@ -1973,6 +2132,98 @@ function textOrderMatch(texts, expectedTexts) {
1973
2132
  }
1974
2133
  return { matched: true, positions };
1975
2134
  }
2135
+ function linkStatusSelector(check) {
2136
+ return check.selector || check.link_selector || "a[href]";
2137
+ }
2138
+ function linkStatusAllowedStatuses(check) {
2139
+ if (Array.isArray(check.allowed_statuses) && check.allowed_statuses.length) return check.allowed_statuses;
2140
+ if (typeof check.expected_status === "number" && Number.isFinite(check.expected_status)) return [check.expected_status];
2141
+ return undefined;
2142
+ }
2143
+ function linkStatusIsAllowed(status, check) {
2144
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
2145
+ const allowed = linkStatusAllowedStatuses(check);
2146
+ return Array.isArray(allowed) && allowed.length ? allowed.includes(status) : status >= 200 && status < 400;
2147
+ }
2148
+ function linkStatusResultOk(result, check) {
2149
+ if (!result || typeof result !== "object" || Array.isArray(result)) return false;
2150
+ if (!linkStatusIsAllowed(result.status, check)) return false;
2151
+ if (typeof result.error === "string" && result.error.trim()) return false;
2152
+ if (result.ok === false) return false;
2153
+ if (check.require_nonzero_bytes === true) {
2154
+ const bytes = typeof result.bytes === "number" && Number.isFinite(result.bytes) ? result.bytes : undefined;
2155
+ const contentLength = typeof result.content_length === "number" && Number.isFinite(result.content_length) ? result.content_length : undefined;
2156
+ return (bytes !== undefined && bytes > 0) || (contentLength !== undefined && contentLength > 0);
2157
+ }
2158
+ return true;
2159
+ }
2160
+ function summarizeLinkStatusEvidence(viewport, check) {
2161
+ const selector = linkStatusSelector(check);
2162
+ const linkEvidence = viewport && viewport.link_statuses && viewport.link_statuses[selector];
2163
+ if (!linkEvidence || typeof linkEvidence !== "object" || Array.isArray(linkEvidence)) {
2164
+ return {
2165
+ viewport: viewport && viewport.name,
2166
+ selector,
2167
+ total_count: 0,
2168
+ ok_count: 0,
2169
+ failed_count: 1,
2170
+ failures: [{ code: "link_status_evidence_missing" }],
2171
+ };
2172
+ }
2173
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter((result) => result && typeof result === "object" && !Array.isArray(result)) : [];
2174
+ const totalCount = typeof linkEvidence.total_count === "number" && Number.isFinite(linkEvidence.total_count) ? linkEvidence.total_count : results.length;
2175
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
2176
+ const failures = results
2177
+ .filter((result) => !linkStatusResultOk(result, check))
2178
+ .map((result) => ({
2179
+ code: "link_status_failed",
2180
+ url: typeof result.url === "string" ? result.url : null,
2181
+ status: typeof result.status === "number" && Number.isFinite(result.status) ? result.status : null,
2182
+ method: typeof result.method === "string" ? result.method : null,
2183
+ error: typeof result.error === "string" ? result.error : null,
2184
+ bytes: typeof result.bytes === "number" && Number.isFinite(result.bytes)
2185
+ ? result.bytes
2186
+ : typeof result.content_length === "number" && Number.isFinite(result.content_length)
2187
+ ? result.content_length
2188
+ : null,
2189
+ }));
2190
+ if (typeof linkEvidence.error === "string" && linkEvidence.error.trim()) {
2191
+ failures.push({ code: "link_status_capture_failed", error: linkEvidence.error });
2192
+ }
2193
+ const recordedFailedCount = typeof linkEvidence.failed_count === "number" && Number.isFinite(linkEvidence.failed_count) ? linkEvidence.failed_count : 0;
2194
+ if (!failures.length && recordedFailedCount > 0) {
2195
+ failures.push({
2196
+ code: "link_status_recorded_failures",
2197
+ failed_count: recordedFailedCount,
2198
+ failures: Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [],
2199
+ });
2200
+ }
2201
+ if (linkEvidence.truncated === true) {
2202
+ failures.push({
2203
+ code: "link_status_probe_truncated",
2204
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
2205
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
2206
+ });
2207
+ }
2208
+ if (typeof check.expected_count === "number" && Number.isFinite(check.expected_count) && totalCount !== check.expected_count) {
2209
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
2210
+ }
2211
+ if (typeof check.min_count === "number" && Number.isFinite(check.min_count) && totalCount < check.min_count) {
2212
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
2213
+ }
2214
+ return {
2215
+ viewport: viewport && viewport.name,
2216
+ selector,
2217
+ total_count: totalCount,
2218
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
2219
+ ok_count: typeof linkEvidence.ok_count === "number" ? linkEvidence.ok_count : okCount,
2220
+ failed_count: failures.length,
2221
+ truncated: linkEvidence.truncated === true,
2222
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
2223
+ status_counts: linkEvidence.status_counts && typeof linkEvidence.status_counts === "object" && !Array.isArray(linkEvidence.status_counts) ? linkEvidence.status_counts : {},
2224
+ failures: failures.slice(0, 20),
2225
+ };
2226
+ }
1976
2227
  function frameEvidenceForSelector(viewport, selector) {
1977
2228
  const container = viewport.frames && viewport.frames[selector || ""];
1978
2229
  if (!container || typeof container !== "object" || Array.isArray(container)) return [];
@@ -2626,6 +2877,29 @@ function assessProfile(profile, evidence) {
2626
2877
  });
2627
2878
  continue;
2628
2879
  }
2880
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
2881
+ const selector = linkStatusSelector(check);
2882
+ const summaries = checkViewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
2883
+ const failed = summaries.filter((summary) => summary.failed_count > 0);
2884
+ checks.push({
2885
+ type: check.type,
2886
+ label: check.label || check.type,
2887
+ status: failed.length ? "failed" : "passed",
2888
+ evidence: {
2889
+ selector,
2890
+ expected_count: check.expected_count ?? null,
2891
+ min_count: check.min_count ?? null,
2892
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
2893
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
2894
+ viewports: summaries,
2895
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures)
2896
+ ? summary.failures.map((failure) => ({ viewport: summary.viewport || null, failure }))
2897
+ : []),
2898
+ },
2899
+ message: failed.length ? "Link status failed in " + failed.length + " viewport(s)." : undefined,
2900
+ });
2901
+ continue;
2902
+ }
2629
2903
  if (check.type === "route_inventory") {
2630
2904
  const inventories = checkViewports
2631
2905
  .map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
@@ -2866,6 +3140,10 @@ function textMatches(sample, check) {
2866
3140
  }
2867
3141
  return String(sample || "").includes(check.text || "");
2868
3142
  }
3143
+ function profileCheckAppliesToViewport(check, viewport) {
3144
+ if (!Array.isArray(check.viewports) || !check.viewports.length) return true;
3145
+ return Boolean(viewport && viewport.name && check.viewports.includes(viewport.name));
3146
+ }
2869
3147
  function setupActionType(action) {
2870
3148
  return String(action && action.type ? action.type : "").replace(/-/g, "_");
2871
3149
  }
@@ -2883,6 +3161,30 @@ function setupTextMatches(sample, action) {
2883
3161
  }
2884
3162
  return String(sample || "").includes(action.text || "");
2885
3163
  }
3164
+ async function waitForAnyVisibleSelector(context, selector, timeout) {
3165
+ const deadline = Date.now() + setupNumber(timeout, 15000);
3166
+ let lastReason = "selector_not_found";
3167
+ while (Date.now() <= deadline) {
3168
+ try {
3169
+ const locator = context.locator(selector);
3170
+ const count = await locator.count();
3171
+ if (!count) {
3172
+ lastReason = "selector_not_found";
3173
+ } else {
3174
+ lastReason = "no_visible_match";
3175
+ for (let index = 0; index < count; index += 1) {
3176
+ if (await locator.nth(index).isVisible().catch(() => false)) {
3177
+ return { ok: true, count, index };
3178
+ }
3179
+ }
3180
+ }
3181
+ } catch (error) {
3182
+ lastReason = String(error && error.message ? error.message : error).slice(0, 500);
3183
+ }
3184
+ await page.waitForTimeout(200);
3185
+ }
3186
+ throw new Error("No visible match for selector " + selector + ": " + lastReason);
3187
+ }
2886
3188
  function setupHasOwn(action, key) {
2887
3189
  return Boolean(action) && Object.keys(action).includes(key);
2888
3190
  }
@@ -3216,7 +3518,7 @@ async function executeSetupAction(action, ordinal, viewport) {
3216
3518
  if (type === "wait_for_selector") {
3217
3519
  const scope = await setupActionScope(action, timeout);
3218
3520
  if (!scope.ok) return setupScopeFailure(base, scope);
3219
- await scope.context.waitForSelector(action.selector, { state: "visible", timeout });
3521
+ await waitForAnyVisibleSelector(scope.context, action.selector, timeout);
3220
3522
  return { ...base, ...setupScopeEvidence(scope), ok: true, timeout_ms: timeout };
3221
3523
  }
3222
3524
  if (type === "drag") {
@@ -3684,6 +3986,196 @@ async function selectorTextSequence(selector) {
3684
3986
  };
3685
3987
  }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
3686
3988
  }
3989
+ function linkProbeMaxLinks(check) {
3990
+ const value = Number(check.max_links || check.maxLinks || check.limit || 100);
3991
+ return Number.isInteger(value) && value > 0 ? Math.min(value, 500) : 100;
3992
+ }
3993
+ function linkProbeDedupe(check) {
3994
+ return check.dedupe !== false;
3995
+ }
3996
+ function linkProbeSameOriginOnly(check) {
3997
+ return check.same_origin_only === true;
3998
+ }
3999
+ async function collectLinkCandidates(selector) {
4000
+ return page.locator(selector).evaluateAll((elements) => {
4001
+ const compact = (value, limit) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit || 160);
4002
+ return elements.map((element, index) => {
4003
+ const tag = element && element.tagName ? element.tagName.toLowerCase() : "element";
4004
+ const href = element && typeof element.href === "string" ? element.href : "";
4005
+ const src = element && typeof element.src === "string" ? element.src : "";
4006
+ const currentSrc = element && typeof element.currentSrc === "string" ? element.currentSrc : "";
4007
+ const attrHref = element && typeof element.getAttribute === "function" ? element.getAttribute("href") || "" : "";
4008
+ const attrSrc = element && typeof element.getAttribute === "function" ? element.getAttribute("src") || "" : "";
4009
+ const attrPoster = element && typeof element.getAttribute === "function" ? element.getAttribute("poster") || "" : "";
4010
+ const raw = href || currentSrc || src || attrHref || attrSrc || attrPoster;
4011
+ if (!raw) return null;
4012
+ let url = "";
4013
+ try {
4014
+ url = new URL(raw, location.href).href;
4015
+ } catch {}
4016
+ if (!url) return null;
4017
+ return {
4018
+ index,
4019
+ tag,
4020
+ url,
4021
+ raw,
4022
+ text: compact(element.innerText || element.textContent || "", 160),
4023
+ alt: compact(element.getAttribute && element.getAttribute("alt"), 80),
4024
+ };
4025
+ }).filter(Boolean);
4026
+ }).catch((error) => ({ error: String(error && error.message ? error.message : error).slice(0, 500) }));
4027
+ }
4028
+ function linkProbeAllowed(status, check) {
4029
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
4030
+ const allowed = Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
4031
+ ? check.allowed_statuses
4032
+ : typeof check.expected_status === "number" && Number.isFinite(check.expected_status)
4033
+ ? [check.expected_status]
4034
+ : null;
4035
+ return allowed ? allowed.includes(status) : status >= 200 && status < 400;
4036
+ }
4037
+ function linkProbeResponseFields(response, method) {
4038
+ const contentLengthHeader = response.headers && typeof response.headers.get === "function" ? response.headers.get("content-length") : null;
4039
+ const contentLength = contentLengthHeader && /^\d+$/.test(contentLengthHeader) ? Number(contentLengthHeader) : null;
4040
+ return {
4041
+ method,
4042
+ status: response.status,
4043
+ redirected: Boolean(response.redirected),
4044
+ final_url: response.url || null,
4045
+ content_type: response.headers && typeof response.headers.get === "function" ? response.headers.get("content-type") : null,
4046
+ content_length: contentLength,
4047
+ };
4048
+ }
4049
+ async function probeLinkStatus(candidate, check) {
4050
+ const requireNonzeroBytes = check.require_nonzero_bytes === true;
4051
+ const allowGetFallback = check.allow_get_fallback !== false;
4052
+ const result = {
4053
+ url: candidate.url,
4054
+ tag: candidate.tag,
4055
+ text: candidate.text || null,
4056
+ status: null,
4057
+ method: null,
4058
+ ok: false,
4059
+ content_type: null,
4060
+ content_length: null,
4061
+ bytes: null,
4062
+ redirected: false,
4063
+ final_url: null,
4064
+ error: null,
4065
+ };
4066
+ const applyResponse = async (response, method, readBytes) => {
4067
+ Object.assign(result, linkProbeResponseFields(response, method));
4068
+ if (readBytes || requireNonzeroBytes) {
4069
+ try {
4070
+ const buffer = await response.arrayBuffer();
4071
+ result.bytes = buffer.byteLength;
4072
+ } catch (error) {
4073
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4074
+ }
4075
+ }
4076
+ result.ok = linkProbeAllowed(result.status, check)
4077
+ && (!requireNonzeroBytes || (typeof result.bytes === "number" ? result.bytes > 0 : typeof result.content_length === "number" && result.content_length > 0))
4078
+ && !result.error;
4079
+ };
4080
+ try {
4081
+ const response = await fetch(candidate.url, { method: "HEAD", redirect: "follow", cache: "no-store" });
4082
+ await applyResponse(response, "HEAD", false);
4083
+ if (result.ok || !allowGetFallback) return result;
4084
+ } catch (error) {
4085
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4086
+ if (!allowGetFallback) return result;
4087
+ }
4088
+ try {
4089
+ result.error = null;
4090
+ const response = await fetch(candidate.url, {
4091
+ method: "GET",
4092
+ redirect: "follow",
4093
+ cache: "no-store",
4094
+ headers: { Range: "bytes=0-0" },
4095
+ });
4096
+ await applyResponse(response, "GET", requireNonzeroBytes);
4097
+ return result;
4098
+ } catch (error) {
4099
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
4100
+ result.ok = false;
4101
+ return result;
4102
+ }
4103
+ }
4104
+ async function collectLinkStatus(check) {
4105
+ const selector = linkStatusSelector(check);
4106
+ const candidateResult = await collectLinkCandidates(selector);
4107
+ if (candidateResult && candidateResult.error) {
4108
+ return {
4109
+ version: "riddle-proof.link-status.v1",
4110
+ selector,
4111
+ total_count: 0,
4112
+ discovered_count: 0,
4113
+ ok_count: 0,
4114
+ failed_count: 1,
4115
+ failures: [{ code: "link_status_capture_failed", error: candidateResult.error }],
4116
+ results: [],
4117
+ status_counts: {},
4118
+ error: candidateResult.error,
4119
+ };
4120
+ }
4121
+ const baseUrl = page.url() || targetUrl;
4122
+ let candidates = Array.isArray(candidateResult) ? candidateResult : [];
4123
+ if (linkProbeSameOriginOnly(check)) {
4124
+ const origin = new URL(baseUrl).origin;
4125
+ candidates = candidates.filter((candidate) => {
4126
+ try { return new URL(candidate.url).origin === origin; } catch { return false; }
4127
+ });
4128
+ }
4129
+ const discoveredCount = candidates.length;
4130
+ if (linkProbeDedupe(check)) {
4131
+ const seen = new Set();
4132
+ candidates = candidates.filter((candidate) => {
4133
+ if (seen.has(candidate.url)) return false;
4134
+ seen.add(candidate.url);
4135
+ return true;
4136
+ });
4137
+ }
4138
+ const maxLinks = linkProbeMaxLinks(check);
4139
+ const selected = candidates.slice(0, maxLinks);
4140
+ const results = [];
4141
+ for (const candidate of selected) {
4142
+ results.push(await probeLinkStatus(candidate, check));
4143
+ }
4144
+ const failures = results.filter((result) => !result.ok).map((result) => ({
4145
+ code: "link_status_failed",
4146
+ url: result.url,
4147
+ status: result.status,
4148
+ method: result.method,
4149
+ error: result.error,
4150
+ bytes: result.bytes == null ? result.content_length : result.bytes,
4151
+ }));
4152
+ const statusCounts = {};
4153
+ for (const result of results) {
4154
+ const key = result.status == null ? "error" : String(result.status);
4155
+ statusCounts[key] = (statusCounts[key] || 0) + 1;
4156
+ }
4157
+ return {
4158
+ version: "riddle-proof.link-status.v1",
4159
+ selector,
4160
+ max_links: maxLinks,
4161
+ same_origin_only: linkProbeSameOriginOnly(check),
4162
+ dedupe: linkProbeDedupe(check),
4163
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
4164
+ allowed_statuses: Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
4165
+ ? check.allowed_statuses
4166
+ : typeof check.expected_status === "number"
4167
+ ? [check.expected_status]
4168
+ : ["2xx", "3xx"],
4169
+ discovered_count: discoveredCount,
4170
+ total_count: selected.length,
4171
+ truncated: candidates.length > selected.length,
4172
+ ok_count: results.filter((result) => result.ok).length,
4173
+ failed_count: failures.length,
4174
+ status_counts: statusCounts,
4175
+ failures: failures.slice(0, 20),
4176
+ results,
4177
+ };
4178
+ }
3687
4179
  async function frameEvidence(selector) {
3688
4180
  const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
3689
4181
  let handles = [];
@@ -4042,7 +4534,7 @@ async function collectRouteInventory(check, viewport) {
4042
4534
  try {
4043
4535
  await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 90000 });
4044
4536
  if (profile.target.wait_for_selector) {
4045
- await page.waitForSelector(profile.target.wait_for_selector, { state: "visible", timeout: 15000 }).catch(() => {});
4537
+ await waitForAnyVisibleSelector(page, profile.target.wait_for_selector, 15000).catch(() => {});
4046
4538
  }
4047
4539
  const index = await findInventoryLinkIndex(check, expectedRoute.path);
4048
4540
  if (index < 0) {
@@ -4101,7 +4593,7 @@ async function captureViewport(viewport) {
4101
4593
  }
4102
4594
  if (!navigationError && profile.target.wait_for_selector) {
4103
4595
  try {
4104
- await page.waitForSelector(profile.target.wait_for_selector, { state: "visible", timeout: 15000 });
4596
+ await waitForAnyVisibleSelector(page, profile.target.wait_for_selector, 15000);
4105
4597
  } catch (error) {
4106
4598
  waitError = String(error && error.message ? error.message : error).slice(0, 1000);
4107
4599
  }
@@ -4203,7 +4695,9 @@ async function captureViewport(viewport) {
4203
4695
  const frames = {};
4204
4696
  const text_sequences = {};
4205
4697
  const text_matches = {};
4698
+ const link_statuses = {};
4206
4699
  for (const check of profile.checks || []) {
4700
+ if (!profileCheckAppliesToViewport(check, viewport)) continue;
4207
4701
  if (
4208
4702
  (
4209
4703
  check.type === "selector_visible"
@@ -4227,6 +4721,10 @@ async function captureViewport(viewport) {
4227
4721
  selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
4228
4722
  frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
4229
4723
  }
4724
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
4725
+ const selector = linkStatusSelector(check);
4726
+ link_statuses[selector] = link_statuses[selector] || await collectLinkStatus(check);
4727
+ }
4230
4728
  }
4231
4729
  const screenshotLabel = profileSlug + "-" + viewport.name;
4232
4730
  try {
@@ -4235,7 +4733,7 @@ async function captureViewport(viewport) {
4235
4733
  pageErrors.push({ message: "saveScreenshot failed: " + String(error && error.message ? error.message : error).slice(0, 500) });
4236
4734
  }
4237
4735
  let routeInventory;
4238
- const routeInventoryCheck = (profile.checks || []).find((check) => check.type === "route_inventory");
4736
+ const routeInventoryCheck = (profile.checks || []).find((check) => check.type === "route_inventory" && profileCheckAppliesToViewport(check, viewport));
4239
4737
  const firstViewportName = (profile.target.viewports || [])[0] && (profile.target.viewports || [])[0].name;
4240
4738
  if (routeInventoryCheck && (routeInventoryCheck.run_all_viewports || !firstViewportName || viewport.name === firstViewportName)) {
4241
4739
  try {
@@ -4290,6 +4788,7 @@ async function captureViewport(viewport) {
4290
4788
  frames,
4291
4789
  text_sequences,
4292
4790
  text_matches,
4791
+ link_statuses,
4293
4792
  route_inventory: routeInventory,
4294
4793
  setup_action_results: setupActionResults,
4295
4794
  screenshot_label: screenshotLabel,
@@ -4338,6 +4837,18 @@ function buildProfileEvidence(currentViewports) {
4338
4837
  ),
4339
4838
  })),
4340
4839
  })),
4840
+ link_status: currentViewports
4841
+ .filter((viewport) => viewport.link_statuses && Object.keys(viewport.link_statuses).length)
4842
+ .map((viewport) => ({
4843
+ viewport: viewport.name,
4844
+ selectors: Object.entries(viewport.link_statuses || {}).map(([selector, statusSet]) => ({
4845
+ selector,
4846
+ total_count: statusSet && statusSet.total_count,
4847
+ ok_count: statusSet && statusSet.ok_count,
4848
+ failed_count: statusSet && statusSet.failed_count,
4849
+ truncated: statusSet && statusSet.truncated === true,
4850
+ })),
4851
+ })),
4341
4852
  route_inventory: currentViewports
4342
4853
  .filter((viewport) => viewport.route_inventory)
4343
4854
  .map((viewport) => ({