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.
- package/dist/analysis/accessibility-empathy.d.ts +48 -1
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +766 -92
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts +28 -0
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +218 -21
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/bug-hunter.d.ts +45 -0
- package/dist/analysis/bug-hunter.d.ts.map +1 -1
- package/dist/analysis/bug-hunter.js +211 -64
- package/dist/analysis/bug-hunter.js.map +1 -1
- package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
- package/dist/analysis/competitive-benchmark.js +4 -2
- package/dist/analysis/competitive-benchmark.js.map +1 -1
- package/dist/browser/session-manager.d.ts.map +1 -1
- package/dist/browser/session-manager.js +4 -2
- package/dist/browser/session-manager.js.map +1 -1
- package/dist/browser.d.ts +11 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +64 -15
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +19 -19
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +114 -58
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.js +3 -2
- package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.js +4 -67
- package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.js +4 -0
- package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
- package/dist/mcp-tools/security-layer.d.ts +33 -0
- package/dist/mcp-tools/security-layer.d.ts.map +1 -1
- package/dist/mcp-tools/security-layer.js +191 -1
- package/dist/mcp-tools/security-layer.js.map +1 -1
- package/dist/navigation-errors.d.ts +21 -0
- package/dist/navigation-errors.d.ts.map +1 -0
- package/dist/navigation-errors.js +119 -0
- package/dist/navigation-errors.js.map +1 -0
- package/dist/remediation/llms-txt.d.ts.map +1 -1
- package/dist/remediation/llms-txt.js +3 -1
- package/dist/remediation/llms-txt.js.map +1 -1
- package/dist/remediation/structured-data.d.ts.map +1 -1
- package/dist/remediation/structured-data.js +3 -1
- package/dist/remediation/structured-data.js.map +1 -1
- package/dist/security/script-gate.d.ts +7 -0
- package/dist/security/script-gate.d.ts.map +1 -1
- package/dist/security/script-gate.js +7 -0
- package/dist/security/script-gate.js.map +1 -1
- package/dist/testing/flaky-detection.js +1 -1
- package/dist/testing/flaky-detection.js.map +1 -1
- package/dist/testing/nl-test-suite.d.ts +12 -0
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +92 -14
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/testing/test-repair.js +3 -3
- package/dist/testing/test-repair.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/visual/attention-transport.d.ts +89 -11
- package/dist/visual/attention-transport.d.ts.map +1 -1
- package/dist/visual/attention-transport.js +148 -53
- package/dist/visual/attention-transport.js.map +1 -1
- package/dist/visual/cross-browser.d.ts.map +1 -1
- package/dist/visual/cross-browser.js +3 -1
- package/dist/visual/cross-browser.js.map +1 -1
- package/dist/visual/distance-metrics.js +6 -6
- package/dist/visual/distance-metrics.js.map +1 -1
- package/dist/visual/perceptual-transport.d.ts +14 -3
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +51 -9
- package/dist/visual/perceptual-transport.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
556
|
+
const escapeText = (v) => v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
|
1067
|
-
|
|
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:
|
|
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
|
-
|
|
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) {
|