cbrowser 19.2.2 → 19.2.3

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.
Files changed (86) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts +48 -1
  2. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  3. package/dist/analysis/accessibility-empathy.js +766 -92
  4. package/dist/analysis/accessibility-empathy.js.map +1 -1
  5. package/dist/analysis/agent-ready-audit.d.ts +28 -0
  6. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  7. package/dist/analysis/agent-ready-audit.js +218 -21
  8. package/dist/analysis/agent-ready-audit.js.map +1 -1
  9. package/dist/analysis/bug-hunter.d.ts +45 -0
  10. package/dist/analysis/bug-hunter.d.ts.map +1 -1
  11. package/dist/analysis/bug-hunter.js +211 -64
  12. package/dist/analysis/bug-hunter.js.map +1 -1
  13. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  14. package/dist/analysis/competitive-benchmark.js +4 -2
  15. package/dist/analysis/competitive-benchmark.js.map +1 -1
  16. package/dist/browser/session-manager.d.ts.map +1 -1
  17. package/dist/browser/session-manager.js +4 -2
  18. package/dist/browser/session-manager.js.map +1 -1
  19. package/dist/browser.d.ts +11 -2
  20. package/dist/browser.d.ts.map +1 -1
  21. package/dist/browser.js +64 -15
  22. package/dist/browser.js.map +1 -1
  23. package/dist/cli.js +2 -1
  24. package/dist/cli.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp-server.d.ts.map +1 -1
  30. package/dist/mcp-server.js +19 -19
  31. package/dist/mcp-server.js.map +1 -1
  32. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  33. package/dist/mcp-tools/base/audit-tools.js +114 -58
  34. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  35. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  36. package/dist/mcp-tools/base/browser-state-tools.js +3 -2
  37. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  38. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  39. package/dist/mcp-tools/base/bug-analysis-tools.js +4 -67
  40. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  41. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  42. package/dist/mcp-tools/base/interaction-tools.js +4 -0
  43. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  44. package/dist/mcp-tools/security-layer.d.ts +33 -0
  45. package/dist/mcp-tools/security-layer.d.ts.map +1 -1
  46. package/dist/mcp-tools/security-layer.js +191 -1
  47. package/dist/mcp-tools/security-layer.js.map +1 -1
  48. package/dist/navigation-errors.d.ts +21 -0
  49. package/dist/navigation-errors.d.ts.map +1 -0
  50. package/dist/navigation-errors.js +119 -0
  51. package/dist/navigation-errors.js.map +1 -0
  52. package/dist/remediation/llms-txt.d.ts.map +1 -1
  53. package/dist/remediation/llms-txt.js +3 -1
  54. package/dist/remediation/llms-txt.js.map +1 -1
  55. package/dist/remediation/structured-data.d.ts.map +1 -1
  56. package/dist/remediation/structured-data.js +3 -1
  57. package/dist/remediation/structured-data.js.map +1 -1
  58. package/dist/security/script-gate.d.ts +7 -0
  59. package/dist/security/script-gate.d.ts.map +1 -1
  60. package/dist/security/script-gate.js +7 -0
  61. package/dist/security/script-gate.js.map +1 -1
  62. package/dist/testing/flaky-detection.js +1 -1
  63. package/dist/testing/flaky-detection.js.map +1 -1
  64. package/dist/testing/nl-test-suite.d.ts +12 -0
  65. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  66. package/dist/testing/nl-test-suite.js +92 -14
  67. package/dist/testing/nl-test-suite.js.map +1 -1
  68. package/dist/testing/test-repair.js +3 -3
  69. package/dist/testing/test-repair.js.map +1 -1
  70. package/dist/types.d.ts +51 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/dist/visual/attention-transport.d.ts +89 -11
  74. package/dist/visual/attention-transport.d.ts.map +1 -1
  75. package/dist/visual/attention-transport.js +148 -53
  76. package/dist/visual/attention-transport.js.map +1 -1
  77. package/dist/visual/cross-browser.d.ts.map +1 -1
  78. package/dist/visual/cross-browser.js +3 -1
  79. package/dist/visual/cross-browser.js.map +1 -1
  80. package/dist/visual/distance-metrics.js +6 -6
  81. package/dist/visual/distance-metrics.js.map +1 -1
  82. package/dist/visual/perceptual-transport.d.ts +14 -3
  83. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  84. package/dist/visual/perceptual-transport.js +51 -9
  85. package/dist/visual/perceptual-transport.js.map +1 -1
  86. package/package.json +1 -1
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { VERSION } from "../version.js";
7
7
  import { CBrowser } from "../browser.js";
8
+ import { navigationError } from "../navigation-errors.js";
8
9
  import { launchWithLightpandaFallback, isLightpandaConfigured } from "../lightpanda.js";
9
10
  // ============================================================================
10
11
  // Scoring Methodology & Disclaimers
@@ -249,6 +250,8 @@ async function detectUnlabeledElements(ctx) {
249
250
  selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
250
251
  text: el.textContent?.trim() || '',
251
252
  html: el.outerHTML.slice(0, 200),
253
+ tag: el.tagName.toLowerCase(),
254
+ attributes: Array.from(el.attributes).map(a => [a.name, a.value]),
252
255
  })).filter(el => !el.text) // Only those without visible text
253
256
  );
254
257
  for (const btn of unlabeledButtons) {
@@ -259,7 +262,12 @@ async function detectUnlabeledElements(ctx) {
259
262
  description: `Button without accessible text or aria-label`,
260
263
  detectionMethod: "button-label-check",
261
264
  recommendation: "Add aria-label or visible text to the button",
262
- codeExample: `<button aria-label="Describe action here">...</button>`,
265
+ // The flagged element's own tag, with the aria-label added. Was the
266
+ // literal `<button aria-label="Describe action here">...</button>` for
267
+ // every finding. (2026-10-09)
268
+ codeExample: VOID_ELEMENTS.has(btn.tag)
269
+ ? rebuildOpenTag(btn.tag, btn.attributes, { 'aria-label': NAMELESS_LABEL_PLACEHOLDER })
270
+ : `${rebuildOpenTag(btn.tag, btn.attributes, { 'aria-label': NAMELESS_LABEL_PLACEHOLDER })}<!-- existing content unchanged --></${btn.tag}>`,
263
271
  });
264
272
  summary.elementsWithoutText++;
265
273
  }
@@ -479,6 +487,7 @@ async function detectMissingAltText(ctx) {
479
487
  selector: `img${imgEl.id ? `#${imgEl.id}` : ''}[src="${imgEl.src.slice(0, 50)}..."]`,
480
488
  src: imgEl.src,
481
489
  isDecorative: imgEl.width < 20 || imgEl.height < 20,
490
+ attributes: Array.from(el.attributes).map(a => [a.name, a.value]),
482
491
  };
483
492
  }).filter(el => !el.isDecorative));
484
493
  for (const img of imagesWithoutAlt) {
@@ -489,7 +498,8 @@ async function detectMissingAltText(ctx) {
489
498
  description: "Image without alt text",
490
499
  detectionMethod: "img-alt-check",
491
500
  recommendation: "Add descriptive alt text, or alt=\"\" if decorative",
492
- codeExample: `<img src="..." alt="Description of the image" />`,
501
+ // The flagged <img> as the page has it, with alt added. (2026-10-09)
502
+ codeExample: rebuildOpenTag("img", img.attributes, { alt: "Describe the image, or empty if decorative" }),
493
503
  });
494
504
  summary.missingAriaLabels++;
495
505
  }
@@ -541,6 +551,56 @@ const VOID_ELEMENTS = new Set([
541
551
  */
542
552
  const EXAMPLE_ATTR_MAX = 200;
543
553
  const EXAMPLE_ATTR_HEAD = 80;
554
+ /** Attribute values are quoted, so any quote or angle bracket in page text would otherwise emit malformed HTML. */
555
+ const escapeAttr = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
556
+ const escapeText = (v) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
557
+ /** The marker is plain text inside the quotes, so the example stays valid markup and nobody mistakes the cut value for the real one. */
558
+ const elideAttr = (v) => v.length > EXAMPLE_ATTR_MAX
559
+ ? `${v.slice(0, EXAMPLE_ATTR_HEAD)}[...${v.length - EXAMPLE_ATTR_HEAD} more characters unchanged]`
560
+ : v;
561
+ /**
562
+ * An element's REAL opening tag, with the `set` attributes replaced in place
563
+ * (or appended when the element does not have them). Every other attribute is
564
+ * kept as the page has it, long values elided. Used by the examples that used
565
+ * to be canned literals: a finding about a specific element should show THAT
566
+ * element, not a generic <button>. (2026-10-09)
567
+ */
568
+ function rebuildOpenTag(tag, attributes, set = {}) {
569
+ const pending = new Map(Object.entries(set));
570
+ const parts = attributes.map(([name, value]) => {
571
+ if (pending.has(name)) {
572
+ const replacement = pending.get(name);
573
+ pending.delete(name);
574
+ return ` ${name}="${escapeAttr(replacement)}"`;
575
+ }
576
+ return ` ${name}="${escapeAttr(elideAttr(value))}"`;
577
+ });
578
+ for (const [name, value] of pending)
579
+ parts.push(` ${name}="${escapeAttr(value)}"`);
580
+ return `<${tag}${parts.join('')}>`;
581
+ }
582
+ /** Longest suggested data-testid. */
583
+ const TESTID_MAX = 40;
584
+ /**
585
+ * A data-testid slug for an accessible name, at most TESTID_MAX characters,
586
+ * cut at a word boundary.
587
+ *
588
+ * It was a hard substring(0, 40), so "Sign up for Pro and get 500 bonus
589
+ * credits free" became "sign-up-for-pro-and-get-500-bonus-credit": a testid
590
+ * that names a word the page does not contain. Now the cut lands on the last
591
+ * hyphen inside the limit ("...-500-bonus"), and falls back to a hard cut only
592
+ * for a single word longer than the limit.
593
+ */
594
+ export function slugifyTestId(text) {
595
+ const s = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
596
+ if (s.length <= TESTID_MAX)
597
+ return s;
598
+ // One character past the limit: if it is a hyphen, the first TESTID_MAX
599
+ // characters already end on a whole word.
600
+ const head = s.slice(0, TESTID_MAX + 1);
601
+ const cut = head.lastIndexOf('-');
602
+ return (cut > 0 ? head.slice(0, cut) : s.slice(0, TESTID_MAX)).replace(/-+$/, '');
603
+ }
544
604
  /**
545
605
  * Suggested markup for a findability finding: the element's REAL opening tag
546
606
  * with one attribute added, data-testid.
@@ -555,11 +615,10 @@ const EXAMPLE_ATTR_HEAD = 80;
555
615
  * named by its <img alt> or title REPLACES that name. (2026-10-07)
556
616
  */
557
617
  function buildFindabilityCodeExample(el, usedTestIds) {
558
- // Attribute values are quoted, so any quote or angle bracket in the page
559
- // text would otherwise emit malformed HTML.
560
- const attr = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
561
- // A testid is a slug, so truncating THIS is correct and expected.
562
- const slug = (v) => v.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').substring(0, 40).replace(/-+$/, '');
618
+ const attr = escapeAttr;
619
+ // A testid is a slug, so shortening THIS is correct and expected; it is cut
620
+ // at a word boundary (slugifyTestId).
621
+ const slug = slugifyTestId;
563
622
  const name = el.accessibleName.trim();
564
623
  // Two elements that share a name are flagged precisely because they are
565
624
  // ambiguous; giving both the same testid would leave them ambiguous.
@@ -568,11 +627,7 @@ function buildFindabilityCodeExample(el, usedTestIds) {
568
627
  for (let n = 2; usedTestIds.has(testId); n++)
569
628
  testId = `${base}-${n}`;
570
629
  usedTestIds.add(testId);
571
- // The marker is plain text inside the quotes, so the example stays valid
572
- // markup and nobody mistakes the cut value for the real one.
573
- const shown = (v) => v.length > EXAMPLE_ATTR_MAX
574
- ? `${v.slice(0, EXAMPLE_ATTR_HEAD)}[...${v.length - EXAMPLE_ATTR_HEAD} more characters unchanged]`
575
- : v;
630
+ const shown = elideAttr;
576
631
  let hasAriaLabelAttr = false;
577
632
  const kept = el.attributes.map(([attrName, value]) => {
578
633
  if (attrName === 'aria-label') {
@@ -1057,14 +1112,45 @@ async function detectActionableElements(ctx) {
1057
1112
  ]);
1058
1113
  const weakButtons = [];
1059
1114
  let elementsWithDescribedBy = 0;
1115
+ const clean = (v) => (v || '').replace(/\s+/g, ' ').trim().slice(0, 60);
1116
+ // A container's own name: aria-label, aria-labelledby text, or its first heading.
1117
+ const nameOf = (el) => {
1118
+ const own = clean(el.getAttribute('aria-label'));
1119
+ if (own)
1120
+ return own;
1121
+ const ids = (el.getAttribute('aria-labelledby') || '').trim().split(/\s+/).filter(Boolean);
1122
+ const byIds = clean(ids.map(id => document.getElementById(id)?.textContent || '').join(' '));
1123
+ if (byIds)
1124
+ return byIds;
1125
+ return clean(el.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"], legend, caption')?.textContent);
1126
+ };
1127
+ const contextOf = (btn) => {
1128
+ const controls = (btn.getAttribute('aria-controls') || '').trim().split(/\s+/)[0];
1129
+ const target = controls ? document.getElementById(controls) : null;
1130
+ if (target) {
1131
+ const n = nameOf(target);
1132
+ if (n)
1133
+ return n;
1134
+ }
1135
+ const containers = 'nav, form, section, article, aside, dialog, fieldset, [role="dialog"], [role="region"], [role="navigation"], [role="form"], [role="group"]';
1136
+ for (let c = btn.closest(containers), hops = 0; c && hops < 4; c = c.parentElement?.closest(containers) ?? null, hops++) {
1137
+ const n = nameOf(c);
1138
+ if (n)
1139
+ return n;
1140
+ }
1141
+ return '';
1142
+ };
1060
1143
  buttons.forEach(btn => {
1061
1144
  // The accessible name, in the order a screen reader or role+name lookup
1062
1145
  // resolves it: aria-label="Next slide" names the button even when its
1063
1146
  // visible (aria-hidden) glyph text says "Next". (2026-10-07)
1064
1147
  const labelledBy = (btn.getAttribute('aria-labelledby') || '').trim().split(/\s+/).filter(Boolean)
1065
1148
  .map(id => document.getElementById(id)?.textContent || '').join(' ').trim();
1066
- const text = ((btn.getAttribute('aria-label') || '').trim() || labelledBy || btn.textContent?.trim() || btn.getAttribute('value') || '')
1067
- .replace(/\s+/g, ' ').toLowerCase();
1149
+ const ariaLabel = (btn.getAttribute('aria-label') || '').trim();
1150
+ const content = btn.textContent?.trim() || '';
1151
+ const nameSource = ariaLabel ? 'aria-label' : labelledBy ? 'aria-labelledby' : content ? 'content' : 'value';
1152
+ const raw = (ariaLabel || labelledBy || content || btn.getAttribute('value') || '').replace(/\s+/g, ' ');
1153
+ const text = raw.toLowerCase();
1068
1154
  // Punctuation and arrows are not words: "Next →" is "next", "OK, got it" is "ok got it".
1069
1155
  const words = text.split(/[^\p{L}\p{N}']+/u).filter(Boolean);
1070
1156
  // `every` over zero words is true, so an unnamed icon button needs the length guard.
@@ -1073,6 +1159,14 @@ async function detectActionableElements(ctx) {
1073
1159
  weakButtons.push({
1074
1160
  selector: btn.tagName.toLowerCase() + (btn.id ? `#${btn.id}` : ''),
1075
1161
  text: text.slice(0, 30),
1162
+ label: raw.trim().slice(0, 80),
1163
+ nameSource,
1164
+ labelledBy: (btn.getAttribute('aria-labelledby') || '').trim(),
1165
+ content: content.replace(/\s+/g, ' ').slice(0, 80),
1166
+ tag: btn.tagName.toLowerCase(),
1167
+ attributes: Array.from(btn.attributes).map(a => [a.name, a.value]),
1168
+ hasElementChildren: btn.children.length > 0,
1169
+ context: contextOf(btn),
1076
1170
  });
1077
1171
  }
1078
1172
  // Track aria-describedby usage
@@ -1096,15 +1190,56 @@ async function detectActionableElements(ctx) {
1096
1190
  description: `Button with generic label: "${btn.text}"`,
1097
1191
  detectionMethod: "action-verb-check",
1098
1192
  recommendation: "Use specific action verbs (e.g., 'Save Changes' instead of 'Submit')",
1099
- codeExample: `<!-- Instead of: -->
1100
- <button>Submit</button>
1101
- <!-- Use: -->
1102
- <button>Save Changes</button>
1103
- <button>Create Account</button>
1104
- <button>Download Report</button>`,
1193
+ codeExample: buildGenericLabelExample(btn),
1105
1194
  });
1106
1195
  }
1107
1196
  }
1197
+ /**
1198
+ * Code example for a generic-label finding, built from the flagged element.
1199
+ *
1200
+ * It was one literal for every finding (`<button>Submit</button>` -> "Save
1201
+ * Changes" / "Create Account" / "Download Report"), so a carousel's "Next" and
1202
+ * a newsletter form's "Submit" got the same advice about account creation, in
1203
+ * markup that matched neither. Now: the element as it is, then the same
1204
+ * element with the name changed where the name actually comes from (visible
1205
+ * text, value, or aria-label), naming what the page says it acts on when it
1206
+ * says so (aria-controls target, or nearest named nav/form/section).
1207
+ * Without a context the suggestion is an explicit fill-in.
1208
+ *
1209
+ * Content with child elements (an icon beside the word) is not rewritten; it
1210
+ * gets an aria-label that starts with the visible word, which keeps the
1211
+ * visible label inside the accessible name (WCAG 2.5.3).
1212
+ */
1213
+ function buildGenericLabelExample(b) {
1214
+ const isVoid = VOID_ELEMENTS.has(b.tag);
1215
+ // The element's content as it is: its text, or a marker when it holds other
1216
+ // elements (an icon, a span), which an example must not flatten.
1217
+ const body = b.hasElementChildren
1218
+ ? (b.content ? `<!-- existing content, text "${escapeText(b.content)}" -->` : '<!-- existing content -->')
1219
+ : escapeText(b.content);
1220
+ const before = isVoid
1221
+ ? rebuildOpenTag(b.tag, b.attributes)
1222
+ : `${rebuildOpenTag(b.tag, b.attributes)}${body}</${b.tag}>`;
1223
+ if (b.nameSource === 'aria-labelledby') {
1224
+ return `<!-- Instead of: -->\n${before}\n<!-- Its name comes from aria-labelledby="${escapeAttr(b.labelledBy)}": change that element's text to say what this ${b.tag} does${b.context ? ` in "${escapeText(b.context)}"` : ''}. -->`;
1225
+ }
1226
+ const suggested = b.context ? `${b.label}: ${b.context}` : `${b.label} [what it acts on]`;
1227
+ let after;
1228
+ if (b.nameSource === 'value' || (isVoid && b.nameSource !== 'aria-label')) {
1229
+ after = rebuildOpenTag(b.tag, b.attributes, { value: suggested });
1230
+ }
1231
+ else if (b.nameSource === 'aria-label' || b.hasElementChildren) {
1232
+ const open = rebuildOpenTag(b.tag, b.attributes, { 'aria-label': suggested });
1233
+ after = isVoid ? open : `${open}${body}</${b.tag}>`;
1234
+ }
1235
+ else {
1236
+ after = `${rebuildOpenTag(b.tag, b.attributes)}${escapeText(suggested)}</${b.tag}>`;
1237
+ }
1238
+ const why = b.context
1239
+ ? `<!-- Say what it does. The page names its context "${escapeText(b.context)}", so for example: -->`
1240
+ : `<!-- Say what it does (verb + what it acts on), for example: -->`;
1241
+ return `<!-- Instead of: -->\n${before}\n${why}\n${after}`;
1242
+ }
1108
1243
  /**
1109
1244
  * Detect content-to-navigation ratio
1110
1245
  * @since 17.0.0
@@ -1530,6 +1665,66 @@ function generateRecommendations(issues) {
1530
1665
  }
1531
1666
  return recommendations;
1532
1667
  }
1668
+ /**
1669
+ * One page of an audit's findings for a tool response: issues worst-first,
1670
+ * recommendations in priority order, with what was cut stated and a way to ask
1671
+ * for the rest. Same contract as hunt_bugs (limit/offset, returned, offset,
1672
+ * omitted + omittedNote "Re-run with offset=N").
1673
+ *
1674
+ * agent_ready_audit used a fixed slice(0, 5) for both lists: a page with 40
1675
+ * findings returned five and the other 35 were unreachable, and the five
1676
+ * recommendations were cut with no note at all. The stdio server's copy also
1677
+ * sliced issues in DETECTION order, so a critical found sixth was dropped for
1678
+ * five lows (the HTTP tool got the worst-first fix on 2026-08-01; this one
1679
+ * never did). Both now call this. (2026-10-09)
1680
+ */
1681
+ export function pageAgentReadyFindings(result, options = {}) {
1682
+ const limit = Math.max(1, Math.floor(options.limit ?? 5));
1683
+ const start = Math.max(0, Math.floor(options.offset ?? 0));
1684
+ const rank = (sev) => ({ critical: 0, high: 1, medium: 2, low: 3, info: 4 }[String(sev).toLowerCase()] ?? 5);
1685
+ // Stable: equal severities keep detection order.
1686
+ const sorted = result.issues
1687
+ .map((issue, i) => ({ issue, i }))
1688
+ .sort((a, b) => rank(a.issue.severity) - rank(b.issue.severity) || a.i - b.i)
1689
+ .map(({ issue }) => issue);
1690
+ const shown = sorted.slice(start, start + limit);
1691
+ const omitted = Math.max(0, sorted.length - (start + shown.length));
1692
+ const bySeverity = sorted.reduce((m, i) => {
1693
+ const k = String(i.severity);
1694
+ m[k] = (m[k] ?? 0) + 1;
1695
+ return m;
1696
+ }, {});
1697
+ const severityLine = Object.entries(bySeverity).map(([k, v]) => `${k} ${v}`).join(", ");
1698
+ // Recommendations are NOT walked by `offset`: offset pages the issue list, and
1699
+ // sharing it made page two of the issues silently skip recommendations 1-5.
1700
+ const recs = [...result.recommendations].sort((a, b) => a.priority - b.priority);
1701
+ const recsShown = recs.slice(0, limit);
1702
+ const recsOmitted = Math.max(0, recs.length - recsShown.length);
1703
+ return {
1704
+ topIssues: shown,
1705
+ issuesFound: sorted.length,
1706
+ returned: shown.length,
1707
+ offset: start,
1708
+ ...(omitted > 0
1709
+ ? {
1710
+ issuesOmitted: omitted,
1711
+ omittedNote: `Showing ${shown.length} of ${sorted.length} issues, worst first. Re-run with offset=${start + shown.length} for the next page, or raise limit. Severity counts across ALL findings: ${severityLine}.`,
1712
+ // Pre-19.2.3 field name, kept so existing clients that read it still
1713
+ // see the cut. Same text as omittedNote.
1714
+ issuesNote: `Showing ${shown.length} of ${sorted.length} issues, worst first. Re-run with offset=${start + shown.length} for the next page, or raise limit. Severity counts across ALL findings: ${severityLine}.`,
1715
+ }
1716
+ : {}),
1717
+ bySeverity,
1718
+ topRecommendations: recsShown,
1719
+ recommendationsFound: recs.length,
1720
+ ...(recsOmitted > 0
1721
+ ? {
1722
+ recommendationsOmitted: recsOmitted,
1723
+ recommendationsNote: `Showing the first ${recsShown.length} of ${recs.length} recommendations, in priority order. Raise limit to see more (offset pages the issues only).`,
1724
+ }
1725
+ : {}),
1726
+ };
1727
+ }
1533
1728
  export function formatAgentReadyReport(result) {
1534
1729
  const gradeEmoji = {
1535
1730
  A: '🟢',
@@ -2103,7 +2298,9 @@ export async function runAgentReadyAudit(url, options = {}) {
2103
2298
  `1. Retry — residential proxy IPs rotate and the next one may work\n` +
2104
2299
  `2. Re-run without the proxy to test from your direct IP`);
2105
2300
  }
2106
- throw navError;
2301
+ // Unresolvable host, refused, timed out, bad certificate...: say so
2302
+ // in plain words instead of Playwright's raw error and Call log.
2303
+ throw navigationError(navError, url);
2107
2304
  }
2108
2305
  // v18.22.0: SPA mode - detect framework and wait for hydration
2109
2306
  if (!externalPage) {