cbrowser 19.1.5 → 19.1.7
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.map +1 -1
- package/dist/analysis/accessibility-empathy.js +71 -4
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +318 -68
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/natural-language.d.ts.map +1 -1
- package/dist/analysis/natural-language.js +3 -1
- package/dist/analysis/natural-language.js.map +1 -1
- package/dist/browser.d.ts +72 -8
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +146 -120
- package/dist/browser.js.map +1 -1
- package/dist/charge-scope.d.ts +20 -0
- package/dist/charge-scope.d.ts.map +1 -0
- package/dist/charge-scope.js +46 -0
- package/dist/charge-scope.js.map +1 -0
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +34 -3
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +33 -44
- 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 +6 -1
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.d.ts +11 -0
- package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.js +27 -6
- package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.js +3 -3
- package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/capture-tools.js +3 -1
- package/dist/mcp-tools/base/capture-tools.js.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +6 -37
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.js +6 -5
- package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
- package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/gif-tools.js +2 -0
- package/dist/mcp-tools/base/gif-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 +1 -0
- package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
- package/dist/mcp-tools/base/llms-txt-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/llms-txt-tools.js +1 -0
- package/dist/mcp-tools/base/llms-txt-tools.js.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.js +4 -0
- package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
- package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/navigation-tools.js +2 -1
- package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +2 -0
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/security-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/security-tools.js +2 -0
- package/dist/mcp-tools/base/security-tools.js.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.js +9 -1
- package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
- package/dist/mcp-tools/base/testing-tools.js +1 -1
- package/dist/mcp-tools/base/testing-tools.js.map +1 -1
- package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/values-tools.js +2 -0
- package/dist/mcp-tools/base/values-tools.js.map +1 -1
- package/dist/mcp-tools/credit-refund.d.ts +20 -0
- package/dist/mcp-tools/credit-refund.d.ts.map +1 -0
- package/dist/mcp-tools/credit-refund.js +37 -0
- package/dist/mcp-tools/credit-refund.js.map +1 -0
- package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.js +2 -0
- package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.js +6 -6
- package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
- package/dist/mcp-tools/screenshot-utils.d.ts +15 -0
- package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
- package/dist/mcp-tools/screenshot-utils.js +18 -0
- package/dist/mcp-tools/screenshot-utils.js.map +1 -1
- package/dist/mcp-tools/tier-gate.d.ts.map +1 -1
- package/dist/mcp-tools/tier-gate.js +25 -2
- package/dist/mcp-tools/tier-gate.js.map +1 -1
- package/dist/personas.d.ts +19 -0
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +79 -0
- package/dist/personas.js.map +1 -1
- package/dist/remediation/patch-generator.d.ts.map +1 -1
- package/dist/remediation/patch-generator.js +15 -18
- package/dist/remediation/patch-generator.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +10 -2
- package/dist/visual/perceptual-transport.js.map +1 -1
- package/package.json +1 -1
|
@@ -525,45 +525,216 @@ async function detectBadLinks(ctx) {
|
|
|
525
525
|
});
|
|
526
526
|
}
|
|
527
527
|
}
|
|
528
|
+
/** Fill-in label, used ONLY for an element that has no accessible name at all. */
|
|
529
|
+
const NAMELESS_LABEL_PLACEHOLDER = "Describe the action";
|
|
530
|
+
/** Elements with no closing tag; [role="button"] can land on any of them. */
|
|
531
|
+
const VOID_ELEMENTS = new Set([
|
|
532
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",
|
|
533
|
+
]);
|
|
534
|
+
/**
|
|
535
|
+
* Attribute values longer than this are elided in a suggested example, keeping
|
|
536
|
+
* the first EXAMPLE_ATTR_HEAD characters and saying how many were left out.
|
|
537
|
+
* Copying every value verbatim turned one <a href="data:text/csv;base64,..."> on
|
|
538
|
+
* a fixture into a 200,092-char codeExample, sent three times over (example,
|
|
539
|
+
* recommendation snippet, patch "after"): the audit JSON went from 6,292 to
|
|
540
|
+
* 406,336 bytes. elementHtml is capped at 600 for the same reason. (2026-10-07)
|
|
541
|
+
*/
|
|
542
|
+
const EXAMPLE_ATTR_MAX = 200;
|
|
543
|
+
const EXAMPLE_ATTR_HEAD = 80;
|
|
544
|
+
/**
|
|
545
|
+
* Suggested markup for a findability finding: the element's REAL opening tag
|
|
546
|
+
* with one attribute added, data-testid.
|
|
547
|
+
*
|
|
548
|
+
* Measured 2026-10-06 on cbrowser.ai: this example was built from textContent
|
|
549
|
+
* alone, so an icon button named aria-label="Previous slide" came back as
|
|
550
|
+
* <button data-testid="button" aria-label="button action">...</button>
|
|
551
|
+
* which deletes the real name and every other attribute. remediation_patches
|
|
552
|
+
* then shipped that as the fix. Now nothing the element already has is
|
|
553
|
+
* dropped or overridden. An aria-label is added only when the element has no
|
|
554
|
+
* accessible name from any source, because a placeholder aria-label on a link
|
|
555
|
+
* named by its <img alt> or title REPLACES that name. (2026-10-07)
|
|
556
|
+
*/
|
|
557
|
+
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/-+$/, '');
|
|
563
|
+
const name = el.accessibleName.trim();
|
|
564
|
+
// Two elements that share a name are flagged precisely because they are
|
|
565
|
+
// ambiguous; giving both the same testid would leave them ambiguous.
|
|
566
|
+
const base = slug(name) || slug(NAMELESS_LABEL_PLACEHOLDER);
|
|
567
|
+
let testId = base;
|
|
568
|
+
for (let n = 2; usedTestIds.has(testId); n++)
|
|
569
|
+
testId = `${base}-${n}`;
|
|
570
|
+
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;
|
|
576
|
+
let hasAriaLabelAttr = false;
|
|
577
|
+
const kept = el.attributes.map(([attrName, value]) => {
|
|
578
|
+
if (attrName === 'aria-label') {
|
|
579
|
+
hasAriaLabelAttr = true;
|
|
580
|
+
// aria-label=" " names nothing. Fill it in place rather than appending
|
|
581
|
+
// a second aria-label, which is invalid markup.
|
|
582
|
+
if (!value.trim() && !name)
|
|
583
|
+
return ` aria-label="${attr(NAMELESS_LABEL_PLACEHOLDER)}"`;
|
|
584
|
+
}
|
|
585
|
+
return ` ${attrName}="${attr(shown(value))}"`;
|
|
586
|
+
}).join('');
|
|
587
|
+
const addedLabel = !name && !hasAriaLabelAttr ? ` aria-label="${attr(NAMELESS_LABEL_PLACEHOLDER)}"` : '';
|
|
588
|
+
const open = `<${el.tag} data-testid="${attr(testId)}"${kept}${addedLabel}>`;
|
|
589
|
+
if (VOID_ELEMENTS.has(el.tag))
|
|
590
|
+
return open;
|
|
591
|
+
// Built from fullText, not the 30-char display copy. Using the truncated
|
|
592
|
+
// text here produced a patch that REWROTE the element's visible content:
|
|
593
|
+
// <a data-testid="sign-up-for-pro-and-get-500-bo"
|
|
594
|
+
// aria-label="Sign up for Pro and get 500 bo">Sign up for Pro and get 500 bo</a>
|
|
595
|
+
// Applying that silently amputates the link label on the live page. The
|
|
596
|
+
// recommendation is "add a data-testid", so the example must leave the
|
|
597
|
+
// content exactly as it is. (2026-07-29) Content with child elements (an
|
|
598
|
+
// icon, an <img>, a card's heading + paragraph) is not flattened to text
|
|
599
|
+
// either; it is left alone. (2026-10-07)
|
|
600
|
+
const body = el.hasElementChildren || el.textTruncated
|
|
601
|
+
? '<!-- existing content unchanged -->'
|
|
602
|
+
: el.fullText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
603
|
+
return `${open}${body}</${el.tag}>`;
|
|
604
|
+
}
|
|
528
605
|
/**
|
|
529
606
|
* Detect elements only findable by fuzzy/visual match
|
|
530
607
|
*/
|
|
531
608
|
async function detectLowFindabilityElements(ctx) {
|
|
532
609
|
const { page, issues, summary } = ctx;
|
|
533
610
|
// Check for buttons/links that lack good selectors
|
|
534
|
-
const poorSelectors = await page.$$eval('button, a, [role="button"]', (elements) =>
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
611
|
+
const poorSelectors = await page.$$eval('button, a, [role="button"]', (elements) => {
|
|
612
|
+
// Trimmed: aria-label=" " is not a name and is not a hook.
|
|
613
|
+
const ariaLabel = (e) => (e.getAttribute('aria-label') || '').trim();
|
|
614
|
+
// aria-labelledby ids resolve in the element's OWN tree: a button in an
|
|
615
|
+
// open shadow root is labelled by an id in that shadow root, which
|
|
616
|
+
// document.getElementById cannot see. (2026-10-07)
|
|
617
|
+
const byId = (e, id) => {
|
|
618
|
+
const root = e.getRootNode();
|
|
619
|
+
return typeof root.getElementById === 'function' ? root.getElementById(id) : null;
|
|
620
|
+
};
|
|
621
|
+
// aria-label first, then aria-labelledby resolved to text: the order
|
|
622
|
+
// find_element_by_intent tries them (natural-language.ts SELECTOR_PRIORITY).
|
|
623
|
+
const ariaName = (e) => {
|
|
624
|
+
const label = ariaLabel(e);
|
|
625
|
+
if (label)
|
|
626
|
+
return label;
|
|
627
|
+
const ids = (e.getAttribute('aria-labelledby') || '').trim();
|
|
628
|
+
if (!ids)
|
|
629
|
+
return '';
|
|
630
|
+
return ids.split(/\s+/)
|
|
631
|
+
.map(id => byId(e, id)?.textContent || '')
|
|
632
|
+
.join(' ').replace(/\s+/g, ' ').trim();
|
|
633
|
+
};
|
|
634
|
+
const nameKey = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
635
|
+
// An ARIA name is a stable hook only when it picks out ONE element:
|
|
636
|
+
// two buttons both labelled "Add to cart" match [aria-label="Add to cart"]
|
|
637
|
+
// twice, which is exactly the ambiguity this check exists to report.
|
|
638
|
+
// Counted across open shadow roots too: the elements scored here come
|
|
639
|
+
// from $$eval, which pierces them, and so does a Playwright
|
|
640
|
+
// [aria-label="..."] lookup, so a light-DOM and a shadow-DOM "Open menu"
|
|
641
|
+
// are ambiguous to an agent. (2026-10-07)
|
|
642
|
+
const ariaNameCounts = new Map();
|
|
643
|
+
const countAriaNames = (root) => {
|
|
644
|
+
for (const e of Array.from(root.querySelectorAll('[aria-label], [aria-labelledby]'))) {
|
|
645
|
+
const k = nameKey(ariaName(e));
|
|
646
|
+
if (k)
|
|
647
|
+
ariaNameCounts.set(k, (ariaNameCounts.get(k) || 0) + 1);
|
|
648
|
+
}
|
|
649
|
+
for (const host of Array.from(root.querySelectorAll('*'))) {
|
|
650
|
+
if (host.shadowRoot)
|
|
651
|
+
countAriaNames(host.shadowRoot);
|
|
652
|
+
}
|
|
565
653
|
};
|
|
566
|
-
|
|
654
|
+
countAriaNames(document);
|
|
655
|
+
return elements.map(el => {
|
|
656
|
+
const hasId = !!el.id;
|
|
657
|
+
const hasTestId = el.hasAttribute('data-testid') || el.hasAttribute('data-test') || el.hasAttribute('data-cy');
|
|
658
|
+
const aria = ariaName(el);
|
|
659
|
+
const ariaIsUnique = !!aria && ariaNameCounts.get(nameKey(aria)) === 1;
|
|
660
|
+
const hasName = el.hasAttribute('name');
|
|
661
|
+
const hasGoodClass = el.className && typeof el.className === 'string' &&
|
|
662
|
+
/btn|button|cta|submit|action/i.test(el.className);
|
|
663
|
+
const text = el.textContent?.trim() || '';
|
|
664
|
+
// Score how findable this element is.
|
|
665
|
+
// A unique aria-label weighs the same as id and data-testid. It was 2,
|
|
666
|
+
// so an icon button named aria-label="Previous slide" scored 2/10 and
|
|
667
|
+
// was flagged, while find_element_by_intent resolves it FIRST, at 0.95
|
|
668
|
+
// confidence (natural-language.ts SELECTOR_PRIORITY), and browser.ts
|
|
669
|
+
// ranks aria-label above both testid and id. A name shared with another
|
|
670
|
+
// element keeps its OLD weight, aria-label 2 and aria-labelledby 0 (it
|
|
671
|
+
// was never scored), so a duplicate is flagged exactly as before.
|
|
672
|
+
// Giving a shared aria-labelledby 2 stopped flagging two text buttons
|
|
673
|
+
// labelled by the same "Edit profile" span. (2026-10-07)
|
|
674
|
+
const findabilityScore = (hasId ? 3 : 0) +
|
|
675
|
+
(hasTestId ? 3 : 0) +
|
|
676
|
+
(ariaIsUnique ? 3 : ariaLabel(el) ? 2 : 0) +
|
|
677
|
+
(hasName ? 2 : 0) +
|
|
678
|
+
(hasGoodClass ? 1 : 0) +
|
|
679
|
+
(text.length > 0 && text.length < 50 ? 2 : 0);
|
|
680
|
+
// The element's accessible name from every source, so the suggested
|
|
681
|
+
// markup never invents a name for an element that already has one.
|
|
682
|
+
const imgAlt = [el, ...Array.from(el.querySelectorAll('img'))]
|
|
683
|
+
.filter(e => e.tagName === 'IMG')
|
|
684
|
+
.map(e => (e.getAttribute('alt') || '').trim())
|
|
685
|
+
.find(Boolean) || '';
|
|
686
|
+
const title = (el.getAttribute('title') || '').trim();
|
|
687
|
+
// <input type="submit" value="Send feedback"> is named by its value, a
|
|
688
|
+
// submit or reset input without one by the browser's default label,
|
|
689
|
+
// and an image input by its alt. Missing this put a placeholder
|
|
690
|
+
// aria-label over value="Send feedback". (2026-10-07)
|
|
691
|
+
const inputName = (() => {
|
|
692
|
+
if (el.tagName !== 'INPUT')
|
|
693
|
+
return '';
|
|
694
|
+
const type = (el.getAttribute('type') || '').toLowerCase();
|
|
695
|
+
if (type === 'image')
|
|
696
|
+
return (el.getAttribute('alt') || '').trim();
|
|
697
|
+
if (type !== 'submit' && type !== 'reset' && type !== 'button')
|
|
698
|
+
return '';
|
|
699
|
+
return (el.getAttribute('value') || '').trim() ||
|
|
700
|
+
(type === 'submit' ? 'Submit' : type === 'reset' ? 'Reset' : '');
|
|
701
|
+
})();
|
|
702
|
+
// innerText keeps a card's "Heading" and "Description" apart, where
|
|
703
|
+
// textContent glues them into "HeadingDescription".
|
|
704
|
+
const rendered = (el.innerText ?? '').replace(/\s+/g, ' ').trim();
|
|
705
|
+
// Rendered text beats img alt: in a list of user cards every
|
|
706
|
+
// <a><img alt="Avatar"> Jane Doe</a> has the same alt, so ranking alt
|
|
707
|
+
// first made every testid "avatar", "avatar-2", ... where base gave
|
|
708
|
+
// "jane-doe". alt still names a link that has no text (a logo).
|
|
709
|
+
// title is last, as in the accessible-name computation: it names an
|
|
710
|
+
// element only when the content does not. <a title="a > b">Short link</a>
|
|
711
|
+
// is "Short link", and its testid must be "short-link", not "a-b".
|
|
712
|
+
const accessibleName = aria || rendered || imgAlt || inputName || text || title;
|
|
713
|
+
return {
|
|
714
|
+
selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
|
|
715
|
+
tag: el.tagName.toLowerCase(),
|
|
716
|
+
attributes: Array.from(el.attributes).map(a => [a.name, a.value]),
|
|
717
|
+
accessibleName: accessibleName.slice(0, 200),
|
|
718
|
+
hasElementChildren: el.children.length > 0,
|
|
719
|
+
// Truncated copy, for human-readable descriptions ONLY. Never build
|
|
720
|
+
// suggested markup from this — see fullText. (2026-07-29)
|
|
721
|
+
text: text.slice(0, 30),
|
|
722
|
+
// The real text, for code examples. Capped generously to bound payload
|
|
723
|
+
// size without amputating a normal link label.
|
|
724
|
+
fullText: text.slice(0, 200),
|
|
725
|
+
textTruncated: text.length > 200,
|
|
726
|
+
outerHTML: (el.outerHTML || '').slice(0, 600),
|
|
727
|
+
outerHTMLTruncated: (el.outerHTML || '').length > 600,
|
|
728
|
+
findabilityScore,
|
|
729
|
+
suggestions: {
|
|
730
|
+
needsId: !hasId,
|
|
731
|
+
needsTestId: !hasTestId,
|
|
732
|
+
needsAriaLabel: !accessibleName,
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
}).filter(el => el.findabilityScore < 3);
|
|
736
|
+
});
|
|
737
|
+
const usedTestIds = new Set();
|
|
567
738
|
for (const el of poorSelectors.slice(0, 10)) { // Limit to avoid noise
|
|
568
739
|
issues.push({
|
|
569
740
|
category: "findability",
|
|
@@ -578,24 +749,7 @@ async function detectLowFindabilityElements(ctx) {
|
|
|
578
749
|
: el.suggestions.needsAriaLabel
|
|
579
750
|
? "Add aria-label for accessibility and findability"
|
|
580
751
|
: "Add unique id or data-testid",
|
|
581
|
-
|
|
582
|
-
// text here produced a patch that REWROTE the element's visible content:
|
|
583
|
-
// <a data-testid="sign-up-for-pro-and-get-500-bo"
|
|
584
|
-
// aria-label="Sign up for Pro and get 500 bo">Sign up for Pro and get 500 bo</a>
|
|
585
|
-
// Applying that silently amputates the link label on the live page. The
|
|
586
|
-
// recommendation is "add a data-testid / aria-label", so the example must
|
|
587
|
-
// leave the text exactly as it is. (2026-07-29)
|
|
588
|
-
codeExample: (() => {
|
|
589
|
-
const tag = el.selector.split('#')[0].split('.')[0] || 'button';
|
|
590
|
-
const full = el.fullText || '';
|
|
591
|
-
// A testid is a slug, so truncating THIS is correct and expected.
|
|
592
|
-
const testId = (full || tag).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 40) || tag;
|
|
593
|
-
const label = full || `${tag} action`;
|
|
594
|
-
// Attribute values are quoted, so any quote or angle bracket in the page
|
|
595
|
-
// text would otherwise emit malformed HTML.
|
|
596
|
-
const attr = (v) => v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
597
|
-
return `<${tag} data-testid="${attr(testId)}" aria-label="${attr(label)}">${full || '...'}</${tag}>`;
|
|
598
|
-
})(),
|
|
752
|
+
codeExample: buildFindabilityCodeExample(el, usedTestIds),
|
|
599
753
|
});
|
|
600
754
|
}
|
|
601
755
|
summary.totalElements += poorSelectors.length;
|
|
@@ -879,17 +1033,40 @@ async function detectActionableElements(ctx) {
|
|
|
879
1033
|
const { page, issues } = ctx;
|
|
880
1034
|
const actionAnalysis = await page.evaluate(() => {
|
|
881
1035
|
const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'));
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
//
|
|
885
|
-
|
|
1036
|
+
// A label is generic only when EVERY word in it is. Matching ANY word
|
|
1037
|
+
// called "next slide" generic on cbrowser.ai because it contains "next",
|
|
1038
|
+
// and suggested "Save Changes" for a carousel arrow. "slide" names the
|
|
1039
|
+
// thing acted on, so the label is specific. "click here", "go back" and
|
|
1040
|
+
// "OK, got it" are generic through and through. So is "submit form":
|
|
1041
|
+
// "form" and "button" name the widget, not the outcome, and two forms'
|
|
1042
|
+
// "Submit form" buttons give an agent nothing to choose by.
|
|
1043
|
+
// Filler words (me, now, to, the, please, thanks) are generic too. Without
|
|
1044
|
+
// them "Click me", "Submit now", "Send now", "Go now" and "Yes please",
|
|
1045
|
+
// all flagged by the old any-word rule, stopped being flagged. They add no
|
|
1046
|
+
// information, so "Click to copy" and "Go to the dashboard" stay specific.
|
|
1047
|
+
// show/see/view join read/learn so "Show more" is as generic as "Read
|
|
1048
|
+
// more", while "Show details" is not. (2026-10-07)
|
|
1049
|
+
const genericWords = new Set([
|
|
1050
|
+
'click', 'tap', 'press', 'here', 'this', 'go', 'back', 'next', 'more', 'read', 'learn',
|
|
1051
|
+
'show', 'see', 'view',
|
|
1052
|
+
'continue', 'done', 'submit', 'send', 'ok', 'okay', 'got', 'it', 'yes', 'no',
|
|
1053
|
+
'button', 'link', 'form',
|
|
1054
|
+
'me', 'now', 'to', 'the', 'please', 'thanks',
|
|
1055
|
+
]);
|
|
886
1056
|
const weakButtons = [];
|
|
887
1057
|
let elementsWithDescribedBy = 0;
|
|
888
1058
|
buttons.forEach(btn => {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
//
|
|
892
|
-
const
|
|
1059
|
+
// The accessible name, in the order a screen reader or role+name lookup
|
|
1060
|
+
// resolves it: aria-label="Next slide" names the button even when its
|
|
1061
|
+
// visible (aria-hidden) glyph text says "Next". (2026-10-07)
|
|
1062
|
+
const labelledBy = (btn.getAttribute('aria-labelledby') || '').trim().split(/\s+/).filter(Boolean)
|
|
1063
|
+
.map(id => document.getElementById(id)?.textContent || '').join(' ').trim();
|
|
1064
|
+
const text = ((btn.getAttribute('aria-label') || '').trim() || labelledBy || btn.textContent?.trim() || btn.getAttribute('value') || '')
|
|
1065
|
+
.replace(/\s+/g, ' ').toLowerCase();
|
|
1066
|
+
// Punctuation and arrows are not words: "Next →" is "next", "OK, got it" is "ok got it".
|
|
1067
|
+
const words = text.split(/[^\p{L}\p{N}']+/u).filter(Boolean);
|
|
1068
|
+
// `every` over zero words is true, so an unnamed icon button needs the length guard.
|
|
1069
|
+
const isGeneric = words.length > 0 && words.every(w => genericWords.has(w));
|
|
893
1070
|
if (isGeneric && text.length < 20) {
|
|
894
1071
|
weakButtons.push({
|
|
895
1072
|
selector: btn.tagName.toLowerCase() + (btn.id ? `#${btn.id}` : ''),
|
|
@@ -1225,6 +1402,88 @@ async function detectCaptcha(ctx) {
|
|
|
1225
1402
|
// ============================================================================
|
|
1226
1403
|
// Report Generation
|
|
1227
1404
|
// ============================================================================
|
|
1405
|
+
/**
|
|
1406
|
+
* How to say "N of these" for detections whose per-element description carries
|
|
1407
|
+
* that element's own figures.
|
|
1408
|
+
*
|
|
1409
|
+
* The aggregate line used to be `${count} ${typeIssues[0].description}`, so one
|
|
1410
|
+
* element's details were printed as if they described all of them. Measured
|
|
1411
|
+
* 2026-10-06 on cbrowser.ai: "10 elements lack stable selectors (score: 0/10)"
|
|
1412
|
+
* over per-element scores [0,2,2,2,2,2,0,0,0,0]. On the triage fixture:
|
|
1413
|
+
* "2 sticky element may intercept clicks (z-index: 50, 1264x40px, ...)" over a
|
|
1414
|
+
* sticky bar AND a 300x60 fixed dock at z-index 10, and
|
|
1415
|
+
* "3 Button with generic label: "go"" over "go", "done" and "click here".
|
|
1416
|
+
* Each summary below is true of every issue it counts. The per-issue
|
|
1417
|
+
* descriptions are unchanged; competitive-benchmark matches on them. (2026-10-07)
|
|
1418
|
+
*/
|
|
1419
|
+
const GROUP_SUMMARIES = {
|
|
1420
|
+
"findability-score-check": {
|
|
1421
|
+
pattern: /^Element lacks stable selectors \(score: (\d+)\/10\)$/,
|
|
1422
|
+
summarize: (count, m) => {
|
|
1423
|
+
const scores = m.map(x => Number(x[1]));
|
|
1424
|
+
const lo = Math.min(...scores), hi = Math.max(...scores);
|
|
1425
|
+
return `${count} elements lack stable selectors (${lo === hi ? `score: ${lo}/10` : `scores: ${lo}-${hi}/10`})`;
|
|
1426
|
+
},
|
|
1427
|
+
},
|
|
1428
|
+
"sticky-element-check": {
|
|
1429
|
+
pattern: /^(\w+) element may intercept clicks \(z-index: (-?\d+)/,
|
|
1430
|
+
summarize: (count, m) => {
|
|
1431
|
+
const positions = [...new Set(m.map(x => x[1]))].join(" or ");
|
|
1432
|
+
const z = m.map(x => Number(x[2]));
|
|
1433
|
+
const lo = Math.min(...z), hi = Math.max(...z);
|
|
1434
|
+
return `${count} ${positions} elements may intercept clicks (z-index: ${lo === hi ? lo : `${lo}-${hi}`})`;
|
|
1435
|
+
},
|
|
1436
|
+
},
|
|
1437
|
+
"action-verb-check": {
|
|
1438
|
+
pattern: /^Button with generic label: "(.*)"$/,
|
|
1439
|
+
summarize: (count, m) => {
|
|
1440
|
+
// Repeats collapse: cbrowser.ai's two nav "More" triggers read "more" x2.
|
|
1441
|
+
const seen = new Map();
|
|
1442
|
+
for (const x of m)
|
|
1443
|
+
seen.set(x[1], (seen.get(x[1]) || 0) + 1);
|
|
1444
|
+
const labels = [...seen].map(([label, n]) => `"${label}"${n > 1 ? ` x${n}` : ""}`);
|
|
1445
|
+
return `${count} buttons with generic labels: ${labels.join(", ")}`;
|
|
1446
|
+
},
|
|
1447
|
+
},
|
|
1448
|
+
"clickable-div-check": {
|
|
1449
|
+
pattern: /^Clickable (\S+) without button role$/,
|
|
1450
|
+
// Element types only, at most five. The selector carries the element's id,
|
|
1451
|
+
// so listing selectors printed all 60 ids of a grid of
|
|
1452
|
+
// <div id="cell-N" onclick>, 812 chars in a top recommendation. (2026-10-07)
|
|
1453
|
+
summarize: (count, m) => {
|
|
1454
|
+
const tags = [...new Set(m.map(x => x[1].split("#")[0]))];
|
|
1455
|
+
const more = tags.length > 5 ? `, +${tags.length - 5} more` : "";
|
|
1456
|
+
return `${count} clickable elements without button role (${tags.slice(0, 5).join(", ")}${more})`;
|
|
1457
|
+
},
|
|
1458
|
+
},
|
|
1459
|
+
"link-href-check": {
|
|
1460
|
+
pattern: /^Link with (javascript:|no) href acts as button$/,
|
|
1461
|
+
summarize: (count, m) => `${count} links with ${[...new Set(m.map(x => x[1]))].join(" or ")} href act as buttons`,
|
|
1462
|
+
},
|
|
1463
|
+
};
|
|
1464
|
+
/** One line for a group of same-detection issues that is true of every one of them. */
|
|
1465
|
+
function summarizeIssueGroup(typeIssues) {
|
|
1466
|
+
const representative = typeIssues[0];
|
|
1467
|
+
const count = typeIssues.length;
|
|
1468
|
+
if (count === 1)
|
|
1469
|
+
return representative.description;
|
|
1470
|
+
const summary = GROUP_SUMMARIES[representative.detectionMethod];
|
|
1471
|
+
if (summary) {
|
|
1472
|
+
const matches = typeIssues.map(i => summary.pattern.exec(i.description));
|
|
1473
|
+
if (matches.every((m) => m !== null))
|
|
1474
|
+
return summary.summarize(count, matches);
|
|
1475
|
+
}
|
|
1476
|
+
if (typeIssues.every(i => i.description === representative.description)) {
|
|
1477
|
+
// v14.2.4: Fix grammar - "10 elements lack" not "10 Elements lacks"
|
|
1478
|
+
const issueText = representative.description
|
|
1479
|
+
.replace(/^Element /, "elements ")
|
|
1480
|
+
.replace(/ lacks /, " lack ");
|
|
1481
|
+
return `${count} ${issueText}`;
|
|
1482
|
+
}
|
|
1483
|
+
// Descriptions differ and there is no summary for this detection: say the
|
|
1484
|
+
// quoted one is an example rather than present it as all of them.
|
|
1485
|
+
return `${count} similar issues, for example: ${representative.description}`;
|
|
1486
|
+
}
|
|
1228
1487
|
function generateRecommendations(issues) {
|
|
1229
1488
|
// Group issues by category and sort by severity
|
|
1230
1489
|
const grouped = issues.reduce((acc, issue) => {
|
|
@@ -1253,16 +1512,7 @@ function generateRecommendations(issues) {
|
|
|
1253
1512
|
}
|
|
1254
1513
|
for (const [_type, typeIssues] of issueTypes) {
|
|
1255
1514
|
const representative = typeIssues[0];
|
|
1256
|
-
const
|
|
1257
|
-
// v14.2.4: Fix grammar - "10 elements lack" not "10 Elements lacks"
|
|
1258
|
-
let issueText = representative.description;
|
|
1259
|
-
if (count > 1) {
|
|
1260
|
-
// Replace "Element lacks" with "elements lack" for proper grammar
|
|
1261
|
-
issueText = representative.description
|
|
1262
|
-
.replace(/^Element /, "elements ")
|
|
1263
|
-
.replace(/ lacks /, " lack ");
|
|
1264
|
-
issueText = `${count} ${issueText}`;
|
|
1265
|
-
}
|
|
1515
|
+
const issueText = summarizeIssueGroup(typeIssues);
|
|
1266
1516
|
recommendations.push({
|
|
1267
1517
|
priority: priority++,
|
|
1268
1518
|
category: representative.category,
|