@riddledc/riddle-proof 0.7.89 → 0.7.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -6914,6 +6914,8 @@ var RIDDLE_PROOF_PROFILE_CHECK_TYPES = [
6914
6914
  "frame_no_horizontal_overflow",
6915
6915
  "text_visible",
6916
6916
  "text_absent",
6917
+ "link_status",
6918
+ "artifact_link_status",
6917
6919
  "route_inventory",
6918
6920
  "no_horizontal_overflow",
6919
6921
  "no_mobile_horizontal_overflow",
@@ -7578,6 +7580,29 @@ function normalizeStringList(value, label) {
7578
7580
  if (!values.length) throw new Error(`${label} must contain non-empty strings.`);
7579
7581
  return values;
7580
7582
  }
7583
+ function normalizeHttpStatus(value, label) {
7584
+ if (value === void 0) return void 0;
7585
+ const status = numberValue(value);
7586
+ if (status === void 0 || !Number.isInteger(status) || status < 100 || status > 599) {
7587
+ throw new Error(`${label} must be an HTTP status code.`);
7588
+ }
7589
+ return status;
7590
+ }
7591
+ function normalizeHttpStatuses(value, label) {
7592
+ if (value === void 0) return void 0;
7593
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
7594
+ if (!value.length) throw new Error(`${label} must not be empty.`);
7595
+ const statuses = value.map((item, index) => normalizeHttpStatus(item, `${label}[${index}]`));
7596
+ return Array.from(new Set(statuses));
7597
+ }
7598
+ function normalizePositiveInteger(value, label, max) {
7599
+ if (value === void 0) return void 0;
7600
+ const number = numberValue(value);
7601
+ if (number === void 0 || !Number.isInteger(number) || number < 1 || max !== void 0 && number > max) {
7602
+ throw new Error(`${label} must be an integer from 1 to ${max ?? "unbounded"}.`);
7603
+ }
7604
+ return number;
7605
+ }
7581
7606
  function validateRegexPatterns(patterns, label) {
7582
7607
  for (const pattern of patterns || []) {
7583
7608
  try {
@@ -7621,7 +7646,8 @@ function normalizeCheck(input, index) {
7621
7646
  if (type === "url_search_param_equals" && expectedValue === void 0) {
7622
7647
  throw new Error(`checks[${index}] url_search_param_equals requires expected_value.`);
7623
7648
  }
7624
- if (type === "selector_count_at_least" && numberValue(input.min_count) === void 0) {
7649
+ const minCount = numberValue(input.min_count) ?? numberValue(input.minCount);
7650
+ if (type === "selector_count_at_least" && minCount === void 0) {
7625
7651
  throw new Error(`checks[${index}] selector_count_at_least requires min_count.`);
7626
7652
  }
7627
7653
  const expectedCount = numberValue(input.expected_count) ?? numberValue(input.expectedCount) ?? numberValue(input.count);
@@ -7637,6 +7663,21 @@ function normalizeCheck(input, index) {
7637
7663
  if (type === "route_inventory" && !expectedRoutes?.length) {
7638
7664
  throw new Error(`checks[${index}] route_inventory requires expected_routes.`);
7639
7665
  }
7666
+ const isLinkStatusCheck = type === "link_status" || type === "artifact_link_status";
7667
+ const expectedStatus = isLinkStatusCheck ? normalizeHttpStatus(input.expected_status ?? input.expectedStatus ?? input.status, `checks[${index}] expected_status`) : void 0;
7668
+ const allowedStatuses = isLinkStatusCheck ? normalizeHttpStatuses(
7669
+ input.allowed_statuses ?? input.allowedStatuses ?? input.expected_statuses ?? input.expectedStatuses,
7670
+ `checks[${index}] allowed_statuses`
7671
+ ) : void 0;
7672
+ const maxLinks = isLinkStatusCheck ? normalizePositiveInteger(input.max_links ?? input.maxLinks ?? input.limit, `checks[${index}] max_links`, 500) : void 0;
7673
+ if (isLinkStatusCheck) {
7674
+ if (minCount !== void 0 && (!Number.isInteger(minCount) || minCount < 0)) {
7675
+ throw new Error(`checks[${index}] ${type} min_count must be a non-negative integer.`);
7676
+ }
7677
+ if (expectedCount !== void 0 && (!Number.isInteger(expectedCount) || expectedCount < 0)) {
7678
+ throw new Error(`checks[${index}] ${type} expected_count must be a non-negative integer.`);
7679
+ }
7680
+ }
7640
7681
  return {
7641
7682
  type,
7642
7683
  label: stringValue2(input.label),
@@ -7662,8 +7703,15 @@ function normalizeCheck(input, index) {
7662
7703
  allowed_console_patterns: normalizeStringList(input.allowed_console_patterns ?? input.allowedConsolePatterns ?? input.allow_console_patterns ?? input.allowConsolePatterns, `checks[${index}] allowed_console_patterns`),
7663
7704
  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`),
7664
7705
  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`),
7665
- min_count: numberValue(input.min_count),
7706
+ min_count: minCount,
7666
7707
  expected_count: expectedCount,
7708
+ expected_status: expectedStatus,
7709
+ allowed_statuses: allowedStatuses,
7710
+ max_links: maxLinks,
7711
+ same_origin_only: isLinkStatusCheck ? input.same_origin_only === true || input.sameOriginOnly === true : void 0,
7712
+ dedupe: isLinkStatusCheck ? input.dedupe === false ? false : true : void 0,
7713
+ require_nonzero_bytes: isLinkStatusCheck ? input.require_nonzero_bytes === true || input.requireNonzeroBytes === true : void 0,
7714
+ allow_get_fallback: isLinkStatusCheck ? input.allow_get_fallback === false || input.allowGetFallback === false ? false : true : void 0,
7667
7715
  max_overflow_px: numberValue(input.max_overflow_px),
7668
7716
  timeout_ms: numberValue(input.timeout_ms) ?? numberValue(input.timeoutMs),
7669
7717
  run_direct_routes: input.run_direct_routes === false || input.runDirectRoutes === false ? false : true,
@@ -7756,6 +7804,97 @@ function checkLabel(check) {
7756
7804
  function selectorKey(check) {
7757
7805
  return check.selector || "";
7758
7806
  }
7807
+ function linkStatusSelector(check) {
7808
+ return check.selector || check.link_selector || "a[href]";
7809
+ }
7810
+ function linkStatusAllowedStatuses(check) {
7811
+ if (check.allowed_statuses?.length) return check.allowed_statuses;
7812
+ if (check.expected_status !== void 0) return [check.expected_status];
7813
+ return void 0;
7814
+ }
7815
+ function linkStatusIsAllowed(status, check) {
7816
+ if (status === void 0) return false;
7817
+ const allowed = linkStatusAllowedStatuses(check);
7818
+ return allowed?.length ? allowed.includes(status) : status >= 200 && status < 400;
7819
+ }
7820
+ function linkStatusResultOk(result, check) {
7821
+ const status = numberValue(result.status);
7822
+ if (!linkStatusIsAllowed(status, check)) return false;
7823
+ if (stringValue2(result.error)) return false;
7824
+ if (result.ok === false) return false;
7825
+ if (check.require_nonzero_bytes) {
7826
+ const bytes = numberValue(result.bytes);
7827
+ const contentLength = numberValue(result.content_length);
7828
+ return bytes !== void 0 && bytes > 0 || contentLength !== void 0 && contentLength > 0;
7829
+ }
7830
+ return true;
7831
+ }
7832
+ function linkStatusEvidenceForCheck(viewport, check) {
7833
+ const evidence = viewport.link_statuses?.[linkStatusSelector(check)];
7834
+ return isRecord(evidence) ? evidence : void 0;
7835
+ }
7836
+ function summarizeLinkStatusEvidence(viewport, check) {
7837
+ const linkEvidence = linkStatusEvidenceForCheck(viewport, check);
7838
+ if (!linkEvidence) {
7839
+ return {
7840
+ viewport: viewport.name,
7841
+ selector: linkStatusSelector(check),
7842
+ total_count: 0,
7843
+ ok_count: 0,
7844
+ failed_count: 1,
7845
+ failures: [{ code: "link_status_evidence_missing" }]
7846
+ };
7847
+ }
7848
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter(isRecord) : [];
7849
+ const totalCount = numberValue(linkEvidence.total_count) ?? results.length;
7850
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
7851
+ const failures = results.filter((result) => !linkStatusResultOk(result, check)).map((result) => ({
7852
+ code: "link_status_failed",
7853
+ url: stringValue2(result.url) ?? null,
7854
+ status: numberValue(result.status) ?? null,
7855
+ method: stringValue2(result.method) ?? null,
7856
+ error: stringValue2(result.error) ?? null,
7857
+ bytes: numberValue(result.bytes) ?? numberValue(result.content_length) ?? null
7858
+ }));
7859
+ if (stringValue2(linkEvidence.error)) {
7860
+ failures.push({ code: "link_status_capture_failed", error: stringValue2(linkEvidence.error) ?? "" });
7861
+ }
7862
+ const recordedFailedCount = numberValue(linkEvidence.failed_count) ?? 0;
7863
+ if (!failures.length && recordedFailedCount > 0) {
7864
+ const recordedFailures = Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [];
7865
+ failures.push({
7866
+ code: "link_status_recorded_failures",
7867
+ failed_count: recordedFailedCount,
7868
+ failures: toJsonValue(recordedFailures)
7869
+ });
7870
+ }
7871
+ if (linkEvidence.truncated === true) {
7872
+ failures.push({
7873
+ code: "link_status_probe_truncated",
7874
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
7875
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100
7876
+ });
7877
+ }
7878
+ if (check.expected_count !== void 0 && totalCount !== check.expected_count) {
7879
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
7880
+ }
7881
+ if (check.min_count !== void 0 && totalCount < check.min_count) {
7882
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
7883
+ }
7884
+ const statusCounts = isRecord(linkEvidence.status_counts) ? linkEvidence.status_counts : {};
7885
+ return {
7886
+ viewport: viewport.name,
7887
+ selector: linkStatusSelector(check),
7888
+ total_count: totalCount,
7889
+ discovered_count: numberValue(linkEvidence.discovered_count) ?? totalCount,
7890
+ ok_count: numberValue(linkEvidence.ok_count) ?? okCount,
7891
+ failed_count: failures.length,
7892
+ truncated: linkEvidence.truncated === true,
7893
+ max_links: numberValue(linkEvidence.max_links) ?? check.max_links ?? 100,
7894
+ status_counts: statusCounts,
7895
+ failures: failures.slice(0, 20)
7896
+ };
7897
+ }
7759
7898
  function textKey(check) {
7760
7899
  return check.pattern ? `pattern:${check.pattern}/${check.flags || ""}` : `text:${check.text || ""}`;
7761
7900
  }
@@ -8235,6 +8374,26 @@ function assessCheckFromEvidence(check, evidence) {
8235
8374
  message: failed ? `Text assertion failed in ${failed} viewport(s).` : void 0
8236
8375
  };
8237
8376
  }
8377
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
8378
+ const selector = linkStatusSelector(check);
8379
+ const summaries = viewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
8380
+ const failed = summaries.filter((summary) => (numberValue(summary.failed_count) ?? 0) > 0);
8381
+ return {
8382
+ type: check.type,
8383
+ label: checkLabel(check),
8384
+ status: failed.length ? "failed" : "passed",
8385
+ evidence: {
8386
+ selector,
8387
+ expected_count: check.expected_count ?? null,
8388
+ min_count: check.min_count ?? null,
8389
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
8390
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
8391
+ viewports: summaries.map((summary) => toJsonValue(summary)),
8392
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures) ? summary.failures.map((failure) => toJsonValue({ viewport: stringValue2(summary.viewport) ?? null, failure })) : [])
8393
+ },
8394
+ message: failed.length ? `Link status failed in ${failed.length} viewport(s).` : void 0
8395
+ };
8396
+ }
8238
8397
  if (check.type === "route_inventory") {
8239
8398
  const inventories = viewports.map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory })).filter((item) => isRecord(item.inventory));
8240
8399
  if (!inventories.length) {
@@ -8801,6 +8960,98 @@ function textOrderMatch(texts, expectedTexts) {
8801
8960
  }
8802
8961
  return { matched: true, positions };
8803
8962
  }
8963
+ function linkStatusSelector(check) {
8964
+ return check.selector || check.link_selector || "a[href]";
8965
+ }
8966
+ function linkStatusAllowedStatuses(check) {
8967
+ if (Array.isArray(check.allowed_statuses) && check.allowed_statuses.length) return check.allowed_statuses;
8968
+ if (typeof check.expected_status === "number" && Number.isFinite(check.expected_status)) return [check.expected_status];
8969
+ return undefined;
8970
+ }
8971
+ function linkStatusIsAllowed(status, check) {
8972
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
8973
+ const allowed = linkStatusAllowedStatuses(check);
8974
+ return Array.isArray(allowed) && allowed.length ? allowed.includes(status) : status >= 200 && status < 400;
8975
+ }
8976
+ function linkStatusResultOk(result, check) {
8977
+ if (!result || typeof result !== "object" || Array.isArray(result)) return false;
8978
+ if (!linkStatusIsAllowed(result.status, check)) return false;
8979
+ if (typeof result.error === "string" && result.error.trim()) return false;
8980
+ if (result.ok === false) return false;
8981
+ if (check.require_nonzero_bytes === true) {
8982
+ const bytes = typeof result.bytes === "number" && Number.isFinite(result.bytes) ? result.bytes : undefined;
8983
+ const contentLength = typeof result.content_length === "number" && Number.isFinite(result.content_length) ? result.content_length : undefined;
8984
+ return (bytes !== undefined && bytes > 0) || (contentLength !== undefined && contentLength > 0);
8985
+ }
8986
+ return true;
8987
+ }
8988
+ function summarizeLinkStatusEvidence(viewport, check) {
8989
+ const selector = linkStatusSelector(check);
8990
+ const linkEvidence = viewport && viewport.link_statuses && viewport.link_statuses[selector];
8991
+ if (!linkEvidence || typeof linkEvidence !== "object" || Array.isArray(linkEvidence)) {
8992
+ return {
8993
+ viewport: viewport && viewport.name,
8994
+ selector,
8995
+ total_count: 0,
8996
+ ok_count: 0,
8997
+ failed_count: 1,
8998
+ failures: [{ code: "link_status_evidence_missing" }],
8999
+ };
9000
+ }
9001
+ const results = Array.isArray(linkEvidence.results) ? linkEvidence.results.filter((result) => result && typeof result === "object" && !Array.isArray(result)) : [];
9002
+ const totalCount = typeof linkEvidence.total_count === "number" && Number.isFinite(linkEvidence.total_count) ? linkEvidence.total_count : results.length;
9003
+ const okCount = results.filter((result) => linkStatusResultOk(result, check)).length;
9004
+ const failures = results
9005
+ .filter((result) => !linkStatusResultOk(result, check))
9006
+ .map((result) => ({
9007
+ code: "link_status_failed",
9008
+ url: typeof result.url === "string" ? result.url : null,
9009
+ status: typeof result.status === "number" && Number.isFinite(result.status) ? result.status : null,
9010
+ method: typeof result.method === "string" ? result.method : null,
9011
+ error: typeof result.error === "string" ? result.error : null,
9012
+ bytes: typeof result.bytes === "number" && Number.isFinite(result.bytes)
9013
+ ? result.bytes
9014
+ : typeof result.content_length === "number" && Number.isFinite(result.content_length)
9015
+ ? result.content_length
9016
+ : null,
9017
+ }));
9018
+ if (typeof linkEvidence.error === "string" && linkEvidence.error.trim()) {
9019
+ failures.push({ code: "link_status_capture_failed", error: linkEvidence.error });
9020
+ }
9021
+ const recordedFailedCount = typeof linkEvidence.failed_count === "number" && Number.isFinite(linkEvidence.failed_count) ? linkEvidence.failed_count : 0;
9022
+ if (!failures.length && recordedFailedCount > 0) {
9023
+ failures.push({
9024
+ code: "link_status_recorded_failures",
9025
+ failed_count: recordedFailedCount,
9026
+ failures: Array.isArray(linkEvidence.failures) ? linkEvidence.failures.slice(0, 20) : [],
9027
+ });
9028
+ }
9029
+ if (linkEvidence.truncated === true) {
9030
+ failures.push({
9031
+ code: "link_status_probe_truncated",
9032
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
9033
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
9034
+ });
9035
+ }
9036
+ if (typeof check.expected_count === "number" && Number.isFinite(check.expected_count) && totalCount !== check.expected_count) {
9037
+ failures.push({ code: "link_status_count_mismatch", expected: check.expected_count, actual: totalCount });
9038
+ }
9039
+ if (typeof check.min_count === "number" && Number.isFinite(check.min_count) && totalCount < check.min_count) {
9040
+ failures.push({ code: "link_status_count_below_minimum", min_count: check.min_count, actual: totalCount });
9041
+ }
9042
+ return {
9043
+ viewport: viewport && viewport.name,
9044
+ selector,
9045
+ total_count: totalCount,
9046
+ discovered_count: typeof linkEvidence.discovered_count === "number" ? linkEvidence.discovered_count : totalCount,
9047
+ ok_count: typeof linkEvidence.ok_count === "number" ? linkEvidence.ok_count : okCount,
9048
+ failed_count: failures.length,
9049
+ truncated: linkEvidence.truncated === true,
9050
+ max_links: typeof linkEvidence.max_links === "number" ? linkEvidence.max_links : check.max_links || 100,
9051
+ status_counts: linkEvidence.status_counts && typeof linkEvidence.status_counts === "object" && !Array.isArray(linkEvidence.status_counts) ? linkEvidence.status_counts : {},
9052
+ failures: failures.slice(0, 20),
9053
+ };
9054
+ }
8804
9055
  function frameEvidenceForSelector(viewport, selector) {
8805
9056
  const container = viewport.frames && viewport.frames[selector || ""];
8806
9057
  if (!container || typeof container !== "object" || Array.isArray(container)) return [];
@@ -9454,6 +9705,29 @@ function assessProfile(profile, evidence) {
9454
9705
  });
9455
9706
  continue;
9456
9707
  }
9708
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
9709
+ const selector = linkStatusSelector(check);
9710
+ const summaries = checkViewports.map((viewport) => summarizeLinkStatusEvidence(viewport, check));
9711
+ const failed = summaries.filter((summary) => summary.failed_count > 0);
9712
+ checks.push({
9713
+ type: check.type,
9714
+ label: check.label || check.type,
9715
+ status: failed.length ? "failed" : "passed",
9716
+ evidence: {
9717
+ selector,
9718
+ expected_count: check.expected_count ?? null,
9719
+ min_count: check.min_count ?? null,
9720
+ allowed_statuses: linkStatusAllowedStatuses(check) || ["2xx", "3xx"],
9721
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
9722
+ viewports: summaries,
9723
+ failures: failed.flatMap((summary) => Array.isArray(summary.failures)
9724
+ ? summary.failures.map((failure) => ({ viewport: summary.viewport || null, failure }))
9725
+ : []),
9726
+ },
9727
+ message: failed.length ? "Link status failed in " + failed.length + " viewport(s)." : undefined,
9728
+ });
9729
+ continue;
9730
+ }
9457
9731
  if (check.type === "route_inventory") {
9458
9732
  const inventories = checkViewports
9459
9733
  .map((viewport) => ({ viewport: viewport.name, inventory: viewport.route_inventory }))
@@ -10512,6 +10786,196 @@ async function selectorTextSequence(selector) {
10512
10786
  };
10513
10787
  }).catch((error) => ({ count: 0, visible_count: 0, texts: [], visible_texts: [], error: String(error && error.message ? error.message : error).slice(0, 500) }));
10514
10788
  }
10789
+ function linkProbeMaxLinks(check) {
10790
+ const value = Number(check.max_links || check.maxLinks || check.limit || 100);
10791
+ return Number.isInteger(value) && value > 0 ? Math.min(value, 500) : 100;
10792
+ }
10793
+ function linkProbeDedupe(check) {
10794
+ return check.dedupe !== false;
10795
+ }
10796
+ function linkProbeSameOriginOnly(check) {
10797
+ return check.same_origin_only === true;
10798
+ }
10799
+ async function collectLinkCandidates(selector) {
10800
+ return page.locator(selector).evaluateAll((elements) => {
10801
+ const compact = (value, limit) => String(value || "").replace(/\s+/g, " ").trim().slice(0, limit || 160);
10802
+ return elements.map((element, index) => {
10803
+ const tag = element && element.tagName ? element.tagName.toLowerCase() : "element";
10804
+ const href = element && typeof element.href === "string" ? element.href : "";
10805
+ const src = element && typeof element.src === "string" ? element.src : "";
10806
+ const currentSrc = element && typeof element.currentSrc === "string" ? element.currentSrc : "";
10807
+ const attrHref = element && typeof element.getAttribute === "function" ? element.getAttribute("href") || "" : "";
10808
+ const attrSrc = element && typeof element.getAttribute === "function" ? element.getAttribute("src") || "" : "";
10809
+ const attrPoster = element && typeof element.getAttribute === "function" ? element.getAttribute("poster") || "" : "";
10810
+ const raw = href || currentSrc || src || attrHref || attrSrc || attrPoster;
10811
+ if (!raw) return null;
10812
+ let url = "";
10813
+ try {
10814
+ url = new URL(raw, location.href).href;
10815
+ } catch {}
10816
+ if (!url) return null;
10817
+ return {
10818
+ index,
10819
+ tag,
10820
+ url,
10821
+ raw,
10822
+ text: compact(element.innerText || element.textContent || "", 160),
10823
+ alt: compact(element.getAttribute && element.getAttribute("alt"), 80),
10824
+ };
10825
+ }).filter(Boolean);
10826
+ }).catch((error) => ({ error: String(error && error.message ? error.message : error).slice(0, 500) }));
10827
+ }
10828
+ function linkProbeAllowed(status, check) {
10829
+ if (typeof status !== "number" || !Number.isFinite(status)) return false;
10830
+ const allowed = Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
10831
+ ? check.allowed_statuses
10832
+ : typeof check.expected_status === "number" && Number.isFinite(check.expected_status)
10833
+ ? [check.expected_status]
10834
+ : null;
10835
+ return allowed ? allowed.includes(status) : status >= 200 && status < 400;
10836
+ }
10837
+ function linkProbeResponseFields(response, method) {
10838
+ const contentLengthHeader = response.headers && typeof response.headers.get === "function" ? response.headers.get("content-length") : null;
10839
+ const contentLength = contentLengthHeader && /^\d+$/.test(contentLengthHeader) ? Number(contentLengthHeader) : null;
10840
+ return {
10841
+ method,
10842
+ status: response.status,
10843
+ redirected: Boolean(response.redirected),
10844
+ final_url: response.url || null,
10845
+ content_type: response.headers && typeof response.headers.get === "function" ? response.headers.get("content-type") : null,
10846
+ content_length: contentLength,
10847
+ };
10848
+ }
10849
+ async function probeLinkStatus(candidate, check) {
10850
+ const requireNonzeroBytes = check.require_nonzero_bytes === true;
10851
+ const allowGetFallback = check.allow_get_fallback !== false;
10852
+ const result = {
10853
+ url: candidate.url,
10854
+ tag: candidate.tag,
10855
+ text: candidate.text || null,
10856
+ status: null,
10857
+ method: null,
10858
+ ok: false,
10859
+ content_type: null,
10860
+ content_length: null,
10861
+ bytes: null,
10862
+ redirected: false,
10863
+ final_url: null,
10864
+ error: null,
10865
+ };
10866
+ const applyResponse = async (response, method, readBytes) => {
10867
+ Object.assign(result, linkProbeResponseFields(response, method));
10868
+ if (readBytes || requireNonzeroBytes) {
10869
+ try {
10870
+ const buffer = await response.arrayBuffer();
10871
+ result.bytes = buffer.byteLength;
10872
+ } catch (error) {
10873
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
10874
+ }
10875
+ }
10876
+ result.ok = linkProbeAllowed(result.status, check)
10877
+ && (!requireNonzeroBytes || (typeof result.bytes === "number" ? result.bytes > 0 : typeof result.content_length === "number" && result.content_length > 0))
10878
+ && !result.error;
10879
+ };
10880
+ try {
10881
+ const response = await fetch(candidate.url, { method: "HEAD", redirect: "follow", cache: "no-store" });
10882
+ await applyResponse(response, "HEAD", false);
10883
+ if (result.ok || !allowGetFallback) return result;
10884
+ } catch (error) {
10885
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
10886
+ if (!allowGetFallback) return result;
10887
+ }
10888
+ try {
10889
+ result.error = null;
10890
+ const response = await fetch(candidate.url, {
10891
+ method: "GET",
10892
+ redirect: "follow",
10893
+ cache: "no-store",
10894
+ headers: { Range: "bytes=0-0" },
10895
+ });
10896
+ await applyResponse(response, "GET", requireNonzeroBytes);
10897
+ return result;
10898
+ } catch (error) {
10899
+ result.error = String(error && error.message ? error.message : error).slice(0, 500);
10900
+ result.ok = false;
10901
+ return result;
10902
+ }
10903
+ }
10904
+ async function collectLinkStatus(check) {
10905
+ const selector = linkStatusSelector(check);
10906
+ const candidateResult = await collectLinkCandidates(selector);
10907
+ if (candidateResult && candidateResult.error) {
10908
+ return {
10909
+ version: "riddle-proof.link-status.v1",
10910
+ selector,
10911
+ total_count: 0,
10912
+ discovered_count: 0,
10913
+ ok_count: 0,
10914
+ failed_count: 1,
10915
+ failures: [{ code: "link_status_capture_failed", error: candidateResult.error }],
10916
+ results: [],
10917
+ status_counts: {},
10918
+ error: candidateResult.error,
10919
+ };
10920
+ }
10921
+ const baseUrl = page.url() || targetUrl;
10922
+ let candidates = Array.isArray(candidateResult) ? candidateResult : [];
10923
+ if (linkProbeSameOriginOnly(check)) {
10924
+ const origin = new URL(baseUrl).origin;
10925
+ candidates = candidates.filter((candidate) => {
10926
+ try { return new URL(candidate.url).origin === origin; } catch { return false; }
10927
+ });
10928
+ }
10929
+ const discoveredCount = candidates.length;
10930
+ if (linkProbeDedupe(check)) {
10931
+ const seen = new Set();
10932
+ candidates = candidates.filter((candidate) => {
10933
+ if (seen.has(candidate.url)) return false;
10934
+ seen.add(candidate.url);
10935
+ return true;
10936
+ });
10937
+ }
10938
+ const maxLinks = linkProbeMaxLinks(check);
10939
+ const selected = candidates.slice(0, maxLinks);
10940
+ const results = [];
10941
+ for (const candidate of selected) {
10942
+ results.push(await probeLinkStatus(candidate, check));
10943
+ }
10944
+ const failures = results.filter((result) => !result.ok).map((result) => ({
10945
+ code: "link_status_failed",
10946
+ url: result.url,
10947
+ status: result.status,
10948
+ method: result.method,
10949
+ error: result.error,
10950
+ bytes: result.bytes == null ? result.content_length : result.bytes,
10951
+ }));
10952
+ const statusCounts = {};
10953
+ for (const result of results) {
10954
+ const key = result.status == null ? "error" : String(result.status);
10955
+ statusCounts[key] = (statusCounts[key] || 0) + 1;
10956
+ }
10957
+ return {
10958
+ version: "riddle-proof.link-status.v1",
10959
+ selector,
10960
+ max_links: maxLinks,
10961
+ same_origin_only: linkProbeSameOriginOnly(check),
10962
+ dedupe: linkProbeDedupe(check),
10963
+ require_nonzero_bytes: check.require_nonzero_bytes === true,
10964
+ allowed_statuses: Array.isArray(check.allowed_statuses) && check.allowed_statuses.length
10965
+ ? check.allowed_statuses
10966
+ : typeof check.expected_status === "number"
10967
+ ? [check.expected_status]
10968
+ : ["2xx", "3xx"],
10969
+ discovered_count: discoveredCount,
10970
+ total_count: selected.length,
10971
+ truncated: candidates.length > selected.length,
10972
+ ok_count: results.filter((result) => result.ok).length,
10973
+ failed_count: failures.length,
10974
+ status_counts: statusCounts,
10975
+ failures: failures.slice(0, 20),
10976
+ results,
10977
+ };
10978
+ }
10515
10979
  async function frameEvidence(selector) {
10516
10980
  const result = { selector, count: 0, frame_count: 0, frames: [], errors: [] };
10517
10981
  let handles = [];
@@ -11031,6 +11495,7 @@ async function captureViewport(viewport) {
11031
11495
  const frames = {};
11032
11496
  const text_sequences = {};
11033
11497
  const text_matches = {};
11498
+ const link_statuses = {};
11034
11499
  for (const check of profile.checks || []) {
11035
11500
  if (
11036
11501
  (
@@ -11055,6 +11520,10 @@ async function captureViewport(viewport) {
11055
11520
  selectors[check.selector] = selectors[check.selector] || await selectorStats(check.selector);
11056
11521
  frames[check.selector] = frames[check.selector] || await frameEvidence(check.selector);
11057
11522
  }
11523
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
11524
+ const selector = linkStatusSelector(check);
11525
+ link_statuses[selector] = link_statuses[selector] || await collectLinkStatus(check);
11526
+ }
11058
11527
  }
11059
11528
  const screenshotLabel = profileSlug + "-" + viewport.name;
11060
11529
  try {
@@ -11118,6 +11587,7 @@ async function captureViewport(viewport) {
11118
11587
  frames,
11119
11588
  text_sequences,
11120
11589
  text_matches,
11590
+ link_statuses,
11121
11591
  route_inventory: routeInventory,
11122
11592
  setup_action_results: setupActionResults,
11123
11593
  screenshot_label: screenshotLabel,
@@ -11166,6 +11636,18 @@ function buildProfileEvidence(currentViewports) {
11166
11636
  ),
11167
11637
  })),
11168
11638
  })),
11639
+ link_status: currentViewports
11640
+ .filter((viewport) => viewport.link_statuses)
11641
+ .map((viewport) => ({
11642
+ viewport: viewport.name,
11643
+ selectors: Object.entries(viewport.link_statuses || {}).map(([selector, statusSet]) => ({
11644
+ selector,
11645
+ total_count: statusSet && statusSet.total_count,
11646
+ ok_count: statusSet && statusSet.ok_count,
11647
+ failed_count: statusSet && statusSet.failed_count,
11648
+ truncated: statusSet && statusSet.truncated === true,
11649
+ })),
11650
+ })),
11169
11651
  route_inventory: currentViewports
11170
11652
  .filter((viewport) => viewport.route_inventory)
11171
11653
  .map((viewport) => ({
@@ -11602,6 +12084,10 @@ function profileResultMarkdown(result) {
11602
12084
  if (routeInventorySummaryLines.length) {
11603
12085
  lines.push("", "## Route Inventory", "", ...routeInventorySummaryLines);
11604
12086
  }
12087
+ const linkStatusSummaryLines = profileLinkStatusSummaryMarkdown(result);
12088
+ if (linkStatusSummaryLines.length) {
12089
+ lines.push("", "## Link Status", "", ...linkStatusSummaryLines);
12090
+ }
11605
12091
  const environmentBlockerLines = profileEnvironmentBlockerMarkdown(result);
11606
12092
  if (environmentBlockerLines.length) {
11607
12093
  lines.push("", "## Environment Blocker", "", ...environmentBlockerLines);
@@ -11671,6 +12157,13 @@ function profileCheckMarkdownTarget(check) {
11671
12157
  if (selector && maxOverflow !== void 0) return `${markdownInlineCode(selector)} <= ${maxOverflow}px`;
11672
12158
  return selector ? markdownInlineCode(selector) : maxOverflow !== void 0 ? `<= ${maxOverflow}px` : void 0;
11673
12159
  }
12160
+ if (check.type === "link_status" || check.type === "artifact_link_status") {
12161
+ const expectedCount = cliFiniteNumber(evidence.expected_count);
12162
+ const minCount = cliFiniteNumber(evidence.min_count);
12163
+ if (selector && expectedCount !== void 0) return `${markdownInlineCode(selector)} links = ${expectedCount}`;
12164
+ if (selector && minCount !== void 0) return `${markdownInlineCode(selector)} links >= ${minCount}`;
12165
+ return selector ? markdownInlineCode(selector) : void 0;
12166
+ }
11674
12167
  if (check.type === "no_horizontal_overflow" || check.type === "no_mobile_horizontal_overflow") {
11675
12168
  const maxOverflow = cliFiniteNumber(evidence.max_overflow_px);
11676
12169
  return maxOverflow !== void 0 ? `<= ${maxOverflow}px` : void 0;
@@ -11864,6 +12357,26 @@ function profileRouteInventorySummaryMarkdown(result) {
11864
12357
  }
11865
12358
  return lines;
11866
12359
  }
12360
+ function profileLinkStatusSummaryMarkdown(result) {
12361
+ const linkStatusChecks = result.checks.filter((check) => check.type === "link_status" || check.type === "artifact_link_status");
12362
+ const lines = [];
12363
+ for (const check of linkStatusChecks) {
12364
+ const evidence = cliRecord(check.evidence);
12365
+ if (!evidence) continue;
12366
+ const label = check.label || check.type;
12367
+ const selector = cliString(evidence.selector);
12368
+ const viewports = Array.isArray(evidence.viewports) ? evidence.viewports.map(cliRecord).filter((viewport) => Boolean(viewport)) : [];
12369
+ const totals = viewports.map((viewport) => cliFiniteNumber(viewport.total_count)).filter((count) => count !== void 0);
12370
+ const okTotal = viewports.reduce((sum, viewport) => sum + (cliFiniteNumber(viewport.ok_count) || 0), 0);
12371
+ const failedTotal = viewports.reduce((sum, viewport) => sum + (cliFiniteNumber(viewport.failed_count) || 0), 0);
12372
+ const truncatedTotal = viewports.filter((viewport) => viewport.truncated === true).length;
12373
+ const countText = totals.length ? totals.join("/") : "unknown";
12374
+ lines.push(
12375
+ `- ${label}${selector ? ` ${markdownInlineCode(selector)}` : ""}: links ${countText}, ok ${okTotal}, failures ${failedTotal}${truncatedTotal ? `, truncated viewports ${truncatedTotal}` : ""}`
12376
+ );
12377
+ }
12378
+ return lines;
12379
+ }
11867
12380
  function writeProfileOutput(outputDir, result) {
11868
12381
  if (!outputDir) return;
11869
12382
  (0, import_node_fs6.mkdirSync)(outputDir, { recursive: true });