@wdio/mcp 2.2.0 → 2.3.0

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/lib/server.js CHANGED
@@ -17,7 +17,8 @@ var startBrowserToolDefinition = {
17
17
  headless: z.boolean().optional(),
18
18
  windowWidth: z.number().min(400).max(3840).optional().default(1920),
19
19
  windowHeight: z.number().min(400).max(2160).optional().default(1080),
20
- navigationUrl: z.string().optional().describe("URL to navigate to after starting the browser")
20
+ navigationUrl: z.string().optional().describe("URL to navigate to after starting the browser"),
21
+ capabilities: z.record(z.string(), z.unknown()).optional().describe("Additional W3C capabilities to merge with defaults (e.g. goog:chromeOptions args/extensions/prefs)")
21
22
  }
22
23
  };
23
24
  var closeSessionToolDefinition = {
@@ -45,7 +46,8 @@ var startBrowserTool = async ({
45
46
  headless = false,
46
47
  windowWidth = 1920,
47
48
  windowHeight = 1080,
48
- navigationUrl
49
+ navigationUrl,
50
+ capabilities: userCapabilities = {}
49
51
  }) => {
50
52
  const browserDisplayNames = {
51
53
  chrome: "Chrome",
@@ -98,8 +100,35 @@ var startBrowserTool = async ({
98
100
  capabilities.browserName = "safari";
99
101
  break;
100
102
  }
103
+ const mergeCapabilityOptions = (defaultOptions, customOptions) => {
104
+ if (!defaultOptions || typeof defaultOptions !== "object" || !customOptions || typeof customOptions !== "object") {
105
+ return customOptions ?? defaultOptions;
106
+ }
107
+ const defaultRecord = defaultOptions;
108
+ const customRecord = customOptions;
109
+ const merged = { ...defaultRecord, ...customRecord };
110
+ if (Array.isArray(defaultRecord.args) || Array.isArray(customRecord.args)) {
111
+ merged.args = [
112
+ ...Array.isArray(defaultRecord.args) ? defaultRecord.args : [],
113
+ ...Array.isArray(customRecord.args) ? customRecord.args : []
114
+ ];
115
+ }
116
+ return merged;
117
+ };
118
+ const mergedCapabilities = {
119
+ ...capabilities,
120
+ ...userCapabilities,
121
+ "goog:chromeOptions": mergeCapabilityOptions(capabilities["goog:chromeOptions"], userCapabilities["goog:chromeOptions"]),
122
+ "ms:edgeOptions": mergeCapabilityOptions(capabilities["ms:edgeOptions"], userCapabilities["ms:edgeOptions"]),
123
+ "moz:firefoxOptions": mergeCapabilityOptions(capabilities["moz:firefoxOptions"], userCapabilities["moz:firefoxOptions"])
124
+ };
125
+ for (const [key, value] of Object.entries(mergedCapabilities)) {
126
+ if (value === void 0) {
127
+ delete mergedCapabilities[key];
128
+ }
129
+ }
101
130
  const wdioBrowser = await remote({
102
- capabilities
131
+ capabilities: mergedCapabilities
103
132
  });
104
133
  const { sessionId } = wdioBrowser;
105
134
  state.browsers.set(sessionId, wdioBrowser);
@@ -346,7 +375,8 @@ var startAppToolDefinition = {
346
375
  udid: z5.string().optional().describe('Unique Device Identifier for iOS real device testing (e.g., "00008030-001234567890002E")'),
347
376
  noReset: z5.boolean().optional().describe("Do not reset app state before session (preserves app data). Default: false"),
348
377
  fullReset: z5.boolean().optional().describe("Uninstall app before/after session. Default: true. Set to false with noReset=true to preserve app state completely"),
349
- newCommandTimeout: z5.number().min(0).optional().describe("How long (in seconds) Appium will wait for a new command before assuming the client has quit and ending the session. Default: 60. Set to 300 for 5 minutes, etc.")
378
+ newCommandTimeout: z5.number().min(0).optional().describe("How long (in seconds) Appium will wait for a new command before assuming the client has quit and ending the session. Default: 60. Set to 300 for 5 minutes, etc."),
379
+ capabilities: z5.record(z5.string(), z5.unknown()).optional().describe("Additional Appium/WebDriver capabilities to merge with defaults (e.g. appium:udid, appium:chromedriverExecutable, appium:autoWebview)")
350
380
  }
351
381
  };
352
382
  var getState = () => {
@@ -374,7 +404,8 @@ var startAppTool = async (args) => {
374
404
  udid,
375
405
  noReset,
376
406
  fullReset,
377
- newCommandTimeout
407
+ newCommandTimeout,
408
+ capabilities: userCapabilities = {}
378
409
  } = args;
379
410
  if (!appPath && noReset !== true) {
380
411
  return {
@@ -412,12 +443,21 @@ var startAppTool = async (args) => {
412
443
  fullReset,
413
444
  newCommandTimeout
414
445
  });
446
+ const mergedCapabilities = {
447
+ ...capabilities,
448
+ ...userCapabilities
449
+ };
450
+ for (const [key, value] of Object.entries(mergedCapabilities)) {
451
+ if (value === void 0) {
452
+ delete mergedCapabilities[key];
453
+ }
454
+ }
415
455
  const browser = await remote2({
416
456
  protocol: "http",
417
457
  hostname: serverConfig.hostname,
418
458
  port: serverConfig.port,
419
459
  path: serverConfig.path,
420
- capabilities
460
+ capabilities: mergedCapabilities
421
461
  });
422
462
  const { sessionId } = browser;
423
463
  const shouldAutoDetach = noReset === true || !appPath;
@@ -426,7 +466,7 @@ var startAppTool = async (args) => {
426
466
  state2.currentSession = sessionId;
427
467
  state2.sessionMetadata.set(sessionId, {
428
468
  type: platform.toLowerCase(),
429
- capabilities,
469
+ capabilities: mergedCapabilities,
430
470
  isAttached: shouldAutoDetach
431
471
  });
432
472
  const appInfo = appPath ? `
@@ -483,160 +523,157 @@ var scrollTool = async ({ direction, pixels = 500 }) => {
483
523
  };
484
524
 
485
525
  // src/scripts/get-interactable-browser-elements.ts
486
- var elementsScript = (elementType = "interactable") => (function() {
526
+ var elementsScript = (includeBounds) => (function() {
487
527
  const interactableSelectors = [
488
528
  "a[href]",
489
- // Links with href
490
529
  "button",
491
- // Buttons
492
530
  'input:not([type="hidden"])',
493
- // Input fields (except hidden)
494
531
  "select",
495
- // Select dropdowns
496
532
  "textarea",
497
- // Text areas
498
533
  '[role="button"]',
499
- // Elements with button role
500
534
  '[role="link"]',
501
- // Elements with link role
502
535
  '[role="checkbox"]',
503
- // Elements with checkbox role
504
536
  '[role="radio"]',
505
- // Elements with radio role
506
537
  '[role="tab"]',
507
- // Elements with tab role
508
538
  '[role="menuitem"]',
509
- // Elements with menuitem role
510
539
  '[role="combobox"]',
511
- // Elements with combobox role
512
540
  '[role="option"]',
513
- // Elements with option role
514
541
  '[role="switch"]',
515
- // Elements with switch role
516
542
  '[role="slider"]',
517
- // Elements with slider role
518
543
  '[role="textbox"]',
519
- // Elements with textbox role
520
544
  '[role="searchbox"]',
521
- // Elements with searchbox role
545
+ '[role="spinbutton"]',
522
546
  '[contenteditable="true"]',
523
- // Editable content
524
547
  '[tabindex]:not([tabindex="-1"])'
525
- // Elements with tabindex
526
- ];
527
- const visualSelectors = [
528
- "img",
529
- // Images
530
- "picture",
531
- // Picture elements
532
- "svg",
533
- // SVG graphics
534
- "video",
535
- // Video elements
536
- "canvas",
537
- // Canvas elements
538
- '[style*="background-image"]'
539
- // Elements with background images
540
- ];
548
+ ].join(",");
541
549
  function isVisible(element) {
542
550
  if (typeof element.checkVisibility === "function") {
543
- return element.checkVisibility({
544
- opacityProperty: true,
545
- visibilityProperty: true,
546
- contentVisibilityAuto: true
547
- });
551
+ return element.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true });
548
552
  }
549
553
  const style = window.getComputedStyle(element);
550
554
  return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && element.offsetWidth > 0 && element.offsetHeight > 0;
551
555
  }
552
- function getCssSelector(element) {
553
- if (element.id) {
554
- return `#${CSS.escape(element.id)}`;
556
+ function getAccessibleName(el) {
557
+ const ariaLabel = el.getAttribute("aria-label");
558
+ if (ariaLabel) return ariaLabel.trim();
559
+ const labelledBy = el.getAttribute("aria-labelledby");
560
+ if (labelledBy) {
561
+ const texts = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() || "").filter(Boolean);
562
+ if (texts.length > 0) return texts.join(" ").slice(0, 100);
563
+ }
564
+ const tag = el.tagName.toLowerCase();
565
+ if (tag === "img" || tag === "input" && el.getAttribute("type") === "image") {
566
+ const alt = el.getAttribute("alt");
567
+ if (alt !== null) return alt.trim();
568
+ }
569
+ if (["input", "select", "textarea"].includes(tag)) {
570
+ const id = el.getAttribute("id");
571
+ if (id) {
572
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
573
+ if (label) return label.textContent?.trim() || "";
574
+ }
575
+ const parentLabel = el.closest("label");
576
+ if (parentLabel) {
577
+ const clone = parentLabel.cloneNode(true);
578
+ clone.querySelectorAll("input,select,textarea").forEach((n) => n.remove());
579
+ const lt = clone.textContent?.trim();
580
+ if (lt) return lt;
581
+ }
582
+ }
583
+ const ph = el.getAttribute("placeholder");
584
+ if (ph) return ph.trim();
585
+ const title = el.getAttribute("title");
586
+ if (title) return title.trim();
587
+ return (el.textContent?.trim().replace(/\s+/g, " ") || "").slice(0, 100);
588
+ }
589
+ function getSelector(element) {
590
+ const tag = element.tagName.toLowerCase();
591
+ const text = element.textContent?.trim().replace(/\s+/g, " ");
592
+ if (text && text.length > 0 && text.length <= 50) {
593
+ const sameTagElements = document.querySelectorAll(tag);
594
+ let matchCount = 0;
595
+ sameTagElements.forEach((el) => {
596
+ if (el.textContent?.includes(text)) matchCount++;
597
+ });
598
+ if (matchCount === 1) return `${tag}*=${text}`;
599
+ }
600
+ const ariaLabel = element.getAttribute("aria-label");
601
+ if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
602
+ const testId = element.getAttribute("data-testid");
603
+ if (testId) {
604
+ const sel = `[data-testid="${CSS.escape(testId)}"]`;
605
+ if (document.querySelectorAll(sel).length === 1) return sel;
606
+ }
607
+ if (element.id) return `#${CSS.escape(element.id)}`;
608
+ const nameAttr = element.getAttribute("name");
609
+ if (nameAttr) {
610
+ const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
611
+ if (document.querySelectorAll(sel).length === 1) return sel;
555
612
  }
556
613
  if (element.className && typeof element.className === "string") {
557
614
  const classes = element.className.trim().split(/\s+/).filter(Boolean);
558
- if (classes.length > 0) {
559
- const classSelector = classes.slice(0, 2).map((c) => `.${CSS.escape(c)}`).join("");
560
- const tagWithClass = `${element.tagName.toLowerCase()}${classSelector}`;
561
- if (document.querySelectorAll(tagWithClass).length === 1) {
562
- return tagWithClass;
563
- }
615
+ for (const cls of classes) {
616
+ const sel = `${tag}.${CSS.escape(cls)}`;
617
+ if (document.querySelectorAll(sel).length === 1) return sel;
618
+ }
619
+ if (classes.length >= 2) {
620
+ const sel = `${tag}${classes.slice(0, 2).map((c) => `.${CSS.escape(c)}`).join("")}`;
621
+ if (document.querySelectorAll(sel).length === 1) return sel;
564
622
  }
565
623
  }
566
624
  let current = element;
567
625
  const path = [];
568
626
  while (current && current !== document.documentElement) {
569
- let selector = current.tagName.toLowerCase();
627
+ let seg = current.tagName.toLowerCase();
570
628
  if (current.id) {
571
- selector = `#${CSS.escape(current.id)}`;
572
- path.unshift(selector);
629
+ path.unshift(`#${CSS.escape(current.id)}`);
573
630
  break;
574
631
  }
575
632
  const parent = current.parentElement;
576
633
  if (parent) {
577
- const siblings = Array.from(parent.children).filter(
578
- (child) => child.tagName === current.tagName
579
- );
580
- if (siblings.length > 1) {
581
- const index = siblings.indexOf(current) + 1;
582
- selector += `:nth-child(${index})`;
583
- }
634
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
635
+ if (siblings.length > 1) seg += `:nth-of-type(${siblings.indexOf(current) + 1})`;
584
636
  }
585
- path.unshift(selector);
637
+ path.unshift(seg);
586
638
  current = current.parentElement;
587
- if (path.length >= 4) {
588
- break;
589
- }
639
+ if (path.length >= 4) break;
590
640
  }
591
641
  return path.join(" > ");
592
642
  }
593
- function getElements() {
594
- const selectors = [];
595
- if (elementType === "interactable" || elementType === "all") {
596
- selectors.push(...interactableSelectors);
597
- }
598
- if (elementType === "visual" || elementType === "all") {
599
- selectors.push(...visualSelectors);
600
- }
601
- const allElements = [];
602
- selectors.forEach((selector) => {
603
- const elements = document.querySelectorAll(selector);
604
- elements.forEach((element) => {
605
- if (!allElements.includes(element)) {
606
- allElements.push(element);
607
- }
608
- });
609
- });
610
- const elementInfos = allElements.filter((element) => isVisible(element) && !element.disabled).map((element) => {
611
- const el = element;
612
- const inputEl = element;
613
- const rect = el.getBoundingClientRect();
614
- const isInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth);
615
- const info = {
616
- tagName: el.tagName.toLowerCase(),
617
- type: el.getAttribute("type") || "",
618
- id: el.id || "",
619
- className: (typeof el.className === "string" ? el.className : "") || "",
620
- textContent: el.textContent?.trim() || "",
621
- value: inputEl.value || "",
622
- placeholder: inputEl.placeholder || "",
623
- href: el.getAttribute("href") || "",
624
- ariaLabel: el.getAttribute("aria-label") || "",
625
- role: el.getAttribute("role") || "",
626
- src: el.getAttribute("src") || "",
627
- alt: el.getAttribute("alt") || "",
628
- cssSelector: getCssSelector(el),
629
- isInViewport
643
+ const elements = [];
644
+ const seen = /* @__PURE__ */ new Set();
645
+ document.querySelectorAll(interactableSelectors).forEach((el) => {
646
+ if (seen.has(el)) return;
647
+ seen.add(el);
648
+ const htmlEl = el;
649
+ if (!isVisible(htmlEl)) return;
650
+ const inputEl = htmlEl;
651
+ const rect = htmlEl.getBoundingClientRect();
652
+ const isInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth);
653
+ const entry = {
654
+ tagName: htmlEl.tagName.toLowerCase(),
655
+ name: getAccessibleName(htmlEl),
656
+ type: htmlEl.getAttribute("type") || "",
657
+ value: inputEl.value || "",
658
+ href: htmlEl.getAttribute("href") || "",
659
+ selector: getSelector(htmlEl),
660
+ isInViewport
661
+ };
662
+ if (includeBounds) {
663
+ entry.boundingBox = {
664
+ x: rect.x + window.scrollX,
665
+ y: rect.y + window.scrollY,
666
+ width: rect.width,
667
+ height: rect.height
630
668
  };
631
- return info;
632
- });
633
- return elementInfos;
634
- }
635
- return getElements();
669
+ }
670
+ elements.push(entry);
671
+ });
672
+ return elements;
636
673
  })();
637
- async function getBrowserInteractableElements(browser, options = {}) {
638
- const { elementType = "interactable" } = options;
639
- return browser.execute(elementsScript, elementType);
674
+ async function getInteractableBrowserElements(browser, options = {}) {
675
+ const { includeBounds = false } = options;
676
+ return browser.execute(elementsScript, includeBounds);
640
677
  }
641
678
 
642
679
  // src/locators/constants.ts
@@ -893,7 +930,7 @@ function checkXPathUniqueness(doc, xpathExpr, targetNode) {
893
930
  }
894
931
  if (targetNode) {
895
932
  for (let i = 0; i < nodes.length; i++) {
896
- if (nodes[i].isSameNode(targetNode) || isSameElement(nodes[i], targetNode)) {
933
+ if (nodes[i] === targetNode || isSameElement(nodes[i], targetNode)) {
897
934
  return {
898
935
  isUnique: false,
899
936
  index: i + 1,
@@ -1604,20 +1641,11 @@ import { encode } from "@toon-format/toon";
1604
1641
  import { z as z7 } from "zod";
1605
1642
  var getVisibleElementsToolDefinition = {
1606
1643
  name: "get_visible_elements",
1607
- description: 'get a list of visible (in viewport & displayed) interactable elements on the page (buttons, links, inputs). Use elementType="visual" for images/SVGs. Must prefer this to take_screenshot for interactions',
1644
+ description: "Get interactable elements on the page (buttons, links, inputs). Use get_accessibility for page structure and non-interactable elements.",
1608
1645
  inputSchema: {
1609
- inViewportOnly: z7.boolean().optional().describe(
1610
- "Only return elements within the visible viewport. Default: true. Set to false to get ALL elements on the page."
1611
- ),
1612
- includeContainers: z7.boolean().optional().describe(
1613
- "Include layout containers (ViewGroup, FrameLayout, ScrollView, etc). Default: false. Set to true to see all elements including layouts."
1614
- ),
1615
- includeBounds: z7.boolean().optional().describe(
1616
- "Include element bounds/coordinates (x, y, width, height). Default: false. Set to true for coordinate-based interactions or layout debugging."
1617
- ),
1618
- elementType: z7.enum(["interactable", "visual", "all"]).optional().describe(
1619
- 'Type of elements to return: "interactable" (default) for buttons/links/inputs, "visual" for images/SVGs, "all" for both.'
1620
- ),
1646
+ inViewportOnly: z7.boolean().optional().describe("Only return elements within the visible viewport. Default: true. Set to false to get ALL elements on the page."),
1647
+ includeContainers: z7.boolean().optional().describe("Mobile only: include layout containers. Default: false."),
1648
+ includeBounds: z7.boolean().optional().describe("Include element bounds/coordinates (x, y, width, height). Default: false."),
1621
1649
  limit: z7.number().optional().describe("Maximum number of elements to return. Default: 0 (unlimited)."),
1622
1650
  offset: z7.number().optional().describe("Number of elements to skip (for pagination). Default: 0.")
1623
1651
  }
@@ -1629,7 +1657,6 @@ var getVisibleElementsTool = async (args) => {
1629
1657
  inViewportOnly = true,
1630
1658
  includeContainers = false,
1631
1659
  includeBounds = false,
1632
- elementType = "interactable",
1633
1660
  limit = 0,
1634
1661
  offset = 0
1635
1662
  } = args || {};
@@ -1638,7 +1665,7 @@ var getVisibleElementsTool = async (args) => {
1638
1665
  const platform = browser.isAndroid ? "android" : "ios";
1639
1666
  elements = await getMobileVisibleElements(browser, platform, { includeContainers, includeBounds });
1640
1667
  } else {
1641
- elements = await getBrowserInteractableElements(browser, { elementType });
1668
+ elements = await getInteractableBrowserElements(browser, { includeBounds });
1642
1669
  }
1643
1670
  if (inViewportOnly) {
1644
1671
  elements = elements.filter((el) => el.isInViewport !== false);
@@ -1667,8 +1694,327 @@ var getVisibleElementsTool = async (args) => {
1667
1694
  }
1668
1695
  };
1669
1696
 
1670
- // src/tools/take-screenshot.tool.ts
1697
+ // src/scripts/get-browser-accessibility-tree.ts
1698
+ var accessibilityTreeScript = () => (function() {
1699
+ const INPUT_TYPE_ROLES = {
1700
+ text: "textbox",
1701
+ search: "searchbox",
1702
+ email: "textbox",
1703
+ url: "textbox",
1704
+ tel: "textbox",
1705
+ password: "textbox",
1706
+ number: "spinbutton",
1707
+ checkbox: "checkbox",
1708
+ radio: "radio",
1709
+ range: "slider",
1710
+ submit: "button",
1711
+ reset: "button",
1712
+ image: "button",
1713
+ file: "button",
1714
+ color: "button"
1715
+ };
1716
+ const LANDMARK_ROLES = /* @__PURE__ */ new Set([
1717
+ "navigation",
1718
+ "main",
1719
+ "banner",
1720
+ "contentinfo",
1721
+ "complementary",
1722
+ "form",
1723
+ "dialog",
1724
+ "region"
1725
+ ]);
1726
+ const CONTAINER_ROLES = /* @__PURE__ */ new Set([
1727
+ "navigation",
1728
+ "banner",
1729
+ "contentinfo",
1730
+ "complementary",
1731
+ "main",
1732
+ "form",
1733
+ "region",
1734
+ "group",
1735
+ "list",
1736
+ "listitem",
1737
+ "table",
1738
+ "row",
1739
+ "rowgroup",
1740
+ "generic"
1741
+ ]);
1742
+ function getRole(el) {
1743
+ const explicit = el.getAttribute("role");
1744
+ if (explicit) return explicit.split(" ")[0];
1745
+ const tag = el.tagName.toLowerCase();
1746
+ switch (tag) {
1747
+ case "button":
1748
+ return "button";
1749
+ case "a":
1750
+ return el.hasAttribute("href") ? "link" : null;
1751
+ case "input": {
1752
+ const type = (el.getAttribute("type") || "text").toLowerCase();
1753
+ if (type === "hidden") return null;
1754
+ return INPUT_TYPE_ROLES[type] || "textbox";
1755
+ }
1756
+ case "select":
1757
+ return "combobox";
1758
+ case "textarea":
1759
+ return "textbox";
1760
+ case "h1":
1761
+ case "h2":
1762
+ case "h3":
1763
+ case "h4":
1764
+ case "h5":
1765
+ case "h6":
1766
+ return "heading";
1767
+ case "img":
1768
+ return "img";
1769
+ case "nav":
1770
+ return "navigation";
1771
+ case "main":
1772
+ return "main";
1773
+ case "header":
1774
+ return !el.closest("article,aside,main,nav,section") ? "banner" : null;
1775
+ case "footer":
1776
+ return !el.closest("article,aside,main,nav,section") ? "contentinfo" : null;
1777
+ case "aside":
1778
+ return "complementary";
1779
+ case "dialog":
1780
+ return "dialog";
1781
+ case "form":
1782
+ return "form";
1783
+ case "section":
1784
+ return el.hasAttribute("aria-label") || el.hasAttribute("aria-labelledby") ? "region" : null;
1785
+ case "summary":
1786
+ return "button";
1787
+ case "details":
1788
+ return "group";
1789
+ case "progress":
1790
+ return "progressbar";
1791
+ case "meter":
1792
+ return "meter";
1793
+ case "ul":
1794
+ case "ol":
1795
+ return "list";
1796
+ case "li":
1797
+ return "listitem";
1798
+ case "table":
1799
+ return "table";
1800
+ }
1801
+ if (el.contentEditable === "true") return "textbox";
1802
+ if (el.hasAttribute("tabindex") && parseInt(el.getAttribute("tabindex") || "-1", 10) >= 0) return "generic";
1803
+ return null;
1804
+ }
1805
+ function getAccessibleName(el, role) {
1806
+ const ariaLabel = el.getAttribute("aria-label");
1807
+ if (ariaLabel) return ariaLabel.trim();
1808
+ const labelledBy = el.getAttribute("aria-labelledby");
1809
+ if (labelledBy) {
1810
+ const texts = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent?.trim() || "").filter(Boolean);
1811
+ if (texts.length > 0) return texts.join(" ").slice(0, 100);
1812
+ }
1813
+ const tag = el.tagName.toLowerCase();
1814
+ if (tag === "img" || tag === "input" && el.getAttribute("type") === "image") {
1815
+ const alt = el.getAttribute("alt");
1816
+ if (alt !== null) return alt.trim();
1817
+ }
1818
+ if (["input", "select", "textarea"].includes(tag)) {
1819
+ const id = el.getAttribute("id");
1820
+ if (id) {
1821
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
1822
+ if (label) return label.textContent?.trim() || "";
1823
+ }
1824
+ const parentLabel = el.closest("label");
1825
+ if (parentLabel) {
1826
+ const clone = parentLabel.cloneNode(true);
1827
+ clone.querySelectorAll("input,select,textarea").forEach((n) => n.remove());
1828
+ const lt = clone.textContent?.trim();
1829
+ if (lt) return lt;
1830
+ }
1831
+ }
1832
+ const ph = el.getAttribute("placeholder");
1833
+ if (ph) return ph.trim();
1834
+ const title = el.getAttribute("title");
1835
+ if (title) return title.trim();
1836
+ if (role && CONTAINER_ROLES.has(role)) return "";
1837
+ return (el.textContent?.trim().replace(/\s+/g, " ") || "").slice(0, 100);
1838
+ }
1839
+ function getSelector(element) {
1840
+ const tag = element.tagName.toLowerCase();
1841
+ const text = element.textContent?.trim().replace(/\s+/g, " ");
1842
+ if (text && text.length > 0 && text.length <= 50) {
1843
+ const sameTagElements = document.querySelectorAll(tag);
1844
+ let matchCount = 0;
1845
+ sameTagElements.forEach((el) => {
1846
+ if (el.textContent?.includes(text)) matchCount++;
1847
+ });
1848
+ if (matchCount === 1) return `${tag}*=${text}`;
1849
+ }
1850
+ const ariaLabel = element.getAttribute("aria-label");
1851
+ if (ariaLabel && ariaLabel.length <= 80) return `aria/${ariaLabel}`;
1852
+ const testId = element.getAttribute("data-testid");
1853
+ if (testId) {
1854
+ const sel = `[data-testid="${CSS.escape(testId)}"]`;
1855
+ if (document.querySelectorAll(sel).length === 1) return sel;
1856
+ }
1857
+ if (element.id) return `#${CSS.escape(element.id)}`;
1858
+ const nameAttr = element.getAttribute("name");
1859
+ if (nameAttr) {
1860
+ const sel = `${tag}[name="${CSS.escape(nameAttr)}"]`;
1861
+ if (document.querySelectorAll(sel).length === 1) return sel;
1862
+ }
1863
+ if (element.className && typeof element.className === "string") {
1864
+ const classes = element.className.trim().split(/\s+/).filter(Boolean);
1865
+ for (const cls of classes) {
1866
+ const sel = `${tag}.${CSS.escape(cls)}`;
1867
+ if (document.querySelectorAll(sel).length === 1) return sel;
1868
+ }
1869
+ if (classes.length >= 2) {
1870
+ const sel = `${tag}${classes.slice(0, 2).map((c) => `.${CSS.escape(c)}`).join("")}`;
1871
+ if (document.querySelectorAll(sel).length === 1) return sel;
1872
+ }
1873
+ }
1874
+ let current = element;
1875
+ const path = [];
1876
+ while (current && current !== document.documentElement) {
1877
+ let seg = current.tagName.toLowerCase();
1878
+ if (current.id) {
1879
+ path.unshift(`#${CSS.escape(current.id)}`);
1880
+ break;
1881
+ }
1882
+ const parent = current.parentElement;
1883
+ if (parent) {
1884
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
1885
+ if (siblings.length > 1) seg += `:nth-of-type(${siblings.indexOf(current) + 1})`;
1886
+ }
1887
+ path.unshift(seg);
1888
+ current = current.parentElement;
1889
+ if (path.length >= 4) break;
1890
+ }
1891
+ return path.join(" > ");
1892
+ }
1893
+ function isVisible(el) {
1894
+ if (typeof el.checkVisibility === "function") {
1895
+ return el.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true });
1896
+ }
1897
+ const style = window.getComputedStyle(el);
1898
+ return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && el.offsetWidth > 0 && el.offsetHeight > 0;
1899
+ }
1900
+ function getLevel(el) {
1901
+ const m = el.tagName.toLowerCase().match(/^h([1-6])$/);
1902
+ if (m) return parseInt(m[1], 10);
1903
+ const ariaLevel = el.getAttribute("aria-level");
1904
+ if (ariaLevel) return parseInt(ariaLevel, 10);
1905
+ return void 0;
1906
+ }
1907
+ function getState2(el) {
1908
+ const inputEl = el;
1909
+ const isCheckable = ["input", "menuitemcheckbox", "menuitemradio"].includes(el.tagName.toLowerCase()) || ["checkbox", "radio", "switch"].includes(el.getAttribute("role") || "");
1910
+ return {
1911
+ disabled: el.getAttribute("aria-disabled") === "true" || inputEl.disabled ? "true" : "",
1912
+ checked: isCheckable && inputEl.checked ? "true" : el.getAttribute("aria-checked") || "",
1913
+ expanded: el.getAttribute("aria-expanded") || "",
1914
+ selected: el.getAttribute("aria-selected") || "",
1915
+ pressed: el.getAttribute("aria-pressed") || "",
1916
+ required: inputEl.required || el.getAttribute("aria-required") === "true" ? "true" : "",
1917
+ readonly: inputEl.readOnly || el.getAttribute("aria-readonly") === "true" ? "true" : ""
1918
+ };
1919
+ }
1920
+ const result = [];
1921
+ function walk(el, depth = 0) {
1922
+ if (depth > 200) return;
1923
+ if (!isVisible(el)) return;
1924
+ const role = getRole(el);
1925
+ if (!role) {
1926
+ for (const child of Array.from(el.children)) {
1927
+ walk(child, depth + 1);
1928
+ }
1929
+ return;
1930
+ }
1931
+ const name = getAccessibleName(el, role);
1932
+ const isLandmark = LANDMARK_ROLES.has(role);
1933
+ const hasIdentity = !!(name || isLandmark);
1934
+ const selector = hasIdentity ? getSelector(el) : "";
1935
+ const node = { role, name, selector, level: getLevel(el) ?? "", ...getState2(el) };
1936
+ result.push(node);
1937
+ for (const child of Array.from(el.children)) {
1938
+ walk(child, depth + 1);
1939
+ }
1940
+ }
1941
+ for (const child of Array.from(document.body.children)) {
1942
+ walk(child, 0);
1943
+ }
1944
+ return result;
1945
+ })();
1946
+ async function getBrowserAccessibilityTree(browser) {
1947
+ return browser.execute(accessibilityTreeScript);
1948
+ }
1949
+
1950
+ // src/tools/get-accessibility-tree.tool.ts
1951
+ import { encode as encode2 } from "@toon-format/toon";
1671
1952
  import { z as z8 } from "zod";
1953
+ var getAccessibilityToolDefinition = {
1954
+ name: "get_accessibility",
1955
+ description: "Gets the accessibility tree: page structure with headings, landmarks, and semantic roles. Browser-only. Use to understand page layout and context around interactable elements.",
1956
+ inputSchema: {
1957
+ limit: z8.number().optional().describe("Maximum number of nodes to return. Default: 100. Use 0 for unlimited."),
1958
+ offset: z8.number().optional().describe("Number of nodes to skip (for pagination). Default: 0."),
1959
+ roles: z8.array(z8.string()).optional().describe('Filter to specific roles (e.g., ["heading", "navigation", "region"]). Default: all roles.')
1960
+ }
1961
+ };
1962
+ var getAccessibilityTreeTool = async (args) => {
1963
+ try {
1964
+ const browser = getBrowser();
1965
+ if (browser.isAndroid || browser.isIOS) {
1966
+ return {
1967
+ content: [{
1968
+ type: "text",
1969
+ text: "Error: get_accessibility is browser-only. For mobile apps, use get_visible_elements instead."
1970
+ }]
1971
+ };
1972
+ }
1973
+ const { limit = 100, offset = 0, roles } = args || {};
1974
+ let nodes = await getBrowserAccessibilityTree(browser);
1975
+ if (nodes.length === 0) {
1976
+ return {
1977
+ content: [{ type: "text", text: "No accessibility tree available" }]
1978
+ };
1979
+ }
1980
+ nodes = nodes.filter((n) => n.name && n.name.trim() !== "");
1981
+ if (roles && roles.length > 0) {
1982
+ const roleSet = new Set(roles.map((r) => r.toLowerCase()));
1983
+ nodes = nodes.filter((n) => n.role && roleSet.has(n.role.toLowerCase()));
1984
+ }
1985
+ const total = nodes.length;
1986
+ if (offset > 0) {
1987
+ nodes = nodes.slice(offset);
1988
+ }
1989
+ if (limit > 0) {
1990
+ nodes = nodes.slice(0, limit);
1991
+ }
1992
+ const stateKeys = ["level", "disabled", "checked", "expanded", "selected", "pressed", "required", "readonly"];
1993
+ const usedKeys = stateKeys.filter((k) => nodes.some((n) => n[k] !== ""));
1994
+ const trimmed = nodes.map(({ role, name, selector, ...state2 }) => {
1995
+ const node = { role, name, selector };
1996
+ for (const k of usedKeys) node[k] = state2[k];
1997
+ return node;
1998
+ });
1999
+ const result = {
2000
+ total,
2001
+ showing: trimmed.length,
2002
+ hasMore: offset + trimmed.length < total,
2003
+ nodes: trimmed
2004
+ };
2005
+ const toon = encode2(result).replace(/,""/g, ",").replace(/"",/g, ",");
2006
+ return {
2007
+ content: [{ type: "text", text: toon }]
2008
+ };
2009
+ } catch (e) {
2010
+ return {
2011
+ content: [{ type: "text", text: `Error getting accessibility tree: ${e}` }]
2012
+ };
2013
+ }
2014
+ };
2015
+
2016
+ // src/tools/take-screenshot.tool.ts
2017
+ import { z as z9 } from "zod";
1672
2018
  import sharp from "sharp";
1673
2019
  var MAX_DIMENSION = 2e3;
1674
2020
  var MAX_FILE_SIZE_BYTES = 1024 * 1024;
@@ -1676,7 +2022,7 @@ var takeScreenshotToolDefinition = {
1676
2022
  name: "take_screenshot",
1677
2023
  description: "captures a screenshot of the current page",
1678
2024
  inputSchema: {
1679
- outputPath: z8.string().optional().describe("Optional path where to save the screenshot. If not provided, returns base64 data.")
2025
+ outputPath: z9.string().optional().describe("Optional path where to save the screenshot. If not provided, returns base64 data.")
1680
2026
  }
1681
2027
  };
1682
2028
  async function processScreenshot(screenshotBase64) {
@@ -1728,12 +2074,12 @@ var takeScreenshotTool = async ({ outputPath }) => {
1728
2074
  };
1729
2075
 
1730
2076
  // src/tools/cookies.tool.ts
1731
- import { z as z9 } from "zod";
2077
+ import { z as z10 } from "zod";
1732
2078
  var getCookiesToolDefinition = {
1733
2079
  name: "get_cookies",
1734
2080
  description: "gets all cookies or a specific cookie by name",
1735
2081
  inputSchema: {
1736
- name: z9.string().optional().describe("Optional cookie name to retrieve a specific cookie. If not provided, returns all cookies")
2082
+ name: z10.string().optional().describe("Optional cookie name to retrieve a specific cookie. If not provided, returns all cookies")
1737
2083
  }
1738
2084
  };
1739
2085
  var getCookiesTool = async ({ name }) => {
@@ -1769,14 +2115,14 @@ var setCookieToolDefinition = {
1769
2115
  name: "set_cookie",
1770
2116
  description: "sets a cookie with specified name, value, and optional attributes",
1771
2117
  inputSchema: {
1772
- name: z9.string().describe("Cookie name"),
1773
- value: z9.string().describe("Cookie value"),
1774
- domain: z9.string().optional().describe("Cookie domain (defaults to current domain)"),
1775
- path: z9.string().optional().describe('Cookie path (defaults to "/")'),
1776
- expiry: z9.number().optional().describe("Expiry date as Unix timestamp in seconds"),
1777
- httpOnly: z9.boolean().optional().describe("HttpOnly flag"),
1778
- secure: z9.boolean().optional().describe("Secure flag"),
1779
- sameSite: z9.enum(["strict", "lax", "none"]).optional().describe("SameSite attribute")
2118
+ name: z10.string().describe("Cookie name"),
2119
+ value: z10.string().describe("Cookie value"),
2120
+ domain: z10.string().optional().describe("Cookie domain (defaults to current domain)"),
2121
+ path: z10.string().optional().describe('Cookie path (defaults to "/")'),
2122
+ expiry: z10.number().optional().describe("Expiry date as Unix timestamp in seconds"),
2123
+ httpOnly: z10.boolean().optional().describe("HttpOnly flag"),
2124
+ secure: z10.boolean().optional().describe("Secure flag"),
2125
+ sameSite: z10.enum(["strict", "lax", "none"]).optional().describe("SameSite attribute")
1780
2126
  }
1781
2127
  };
1782
2128
  var setCookieTool = async ({
@@ -1806,7 +2152,7 @@ var deleteCookiesToolDefinition = {
1806
2152
  name: "delete_cookies",
1807
2153
  description: "deletes all cookies or a specific cookie by name",
1808
2154
  inputSchema: {
1809
- name: z9.string().optional().describe("Optional cookie name to delete a specific cookie. If not provided, deletes all cookies")
2155
+ name: z10.string().optional().describe("Optional cookie name to delete a specific cookie. If not provided, deletes all cookies")
1810
2156
  }
1811
2157
  };
1812
2158
  var deleteCookiesTool = async ({ name }) => {
@@ -1829,124 +2175,6 @@ var deleteCookiesTool = async ({ name }) => {
1829
2175
  }
1830
2176
  };
1831
2177
 
1832
- // src/scripts/get-browser-accessibility-tree.ts
1833
- function flattenAccessibilityTree(node, result = []) {
1834
- if (!node) return result;
1835
- if (node.role !== "WebArea" || node.name) {
1836
- const entry = {
1837
- role: node.role || "",
1838
- name: node.name || "",
1839
- value: node.value ?? "",
1840
- description: node.description || "",
1841
- disabled: node.disabled ? "true" : "",
1842
- focused: node.focused ? "true" : "",
1843
- selected: node.selected ? "true" : "",
1844
- checked: node.checked === true ? "true" : node.checked === false ? "false" : node.checked === "mixed" ? "mixed" : "",
1845
- expanded: node.expanded === true ? "true" : node.expanded === false ? "false" : "",
1846
- pressed: node.pressed === true ? "true" : node.pressed === false ? "false" : node.pressed === "mixed" ? "mixed" : "",
1847
- readonly: node.readonly ? "true" : "",
1848
- required: node.required ? "true" : "",
1849
- level: node.level ?? "",
1850
- valuemin: node.valuemin ?? "",
1851
- valuemax: node.valuemax ?? "",
1852
- autocomplete: node.autocomplete || "",
1853
- haspopup: node.haspopup || "",
1854
- invalid: node.invalid ? "true" : "",
1855
- modal: node.modal ? "true" : "",
1856
- multiline: node.multiline ? "true" : "",
1857
- multiselectable: node.multiselectable ? "true" : "",
1858
- orientation: node.orientation || "",
1859
- keyshortcuts: node.keyshortcuts || "",
1860
- roledescription: node.roledescription || "",
1861
- valuetext: node.valuetext || ""
1862
- };
1863
- result.push(entry);
1864
- }
1865
- if (node.children && Array.isArray(node.children)) {
1866
- for (const child of node.children) {
1867
- flattenAccessibilityTree(child, result);
1868
- }
1869
- }
1870
- return result;
1871
- }
1872
- async function getBrowserAccessibilityTree(browser) {
1873
- const puppeteer = await browser.getPuppeteer();
1874
- const pages = await puppeteer.pages();
1875
- if (pages.length === 0) {
1876
- return [];
1877
- }
1878
- const page = pages[0];
1879
- const snapshot = await page.accessibility.snapshot({
1880
- interestingOnly: true
1881
- });
1882
- if (!snapshot) {
1883
- return [];
1884
- }
1885
- return flattenAccessibilityTree(snapshot);
1886
- }
1887
-
1888
- // src/tools/get-accessibility-tree.tool.ts
1889
- import { encode as encode2 } from "@toon-format/toon";
1890
- import { z as z10 } from "zod";
1891
- var getAccessibilityToolDefinition = {
1892
- name: "get_accessibility",
1893
- description: "gets accessibility tree snapshot with semantic information about page elements (roles, names, states). Browser-only - use when get_visible_elements does not return expected elements.",
1894
- inputSchema: {
1895
- limit: z10.number().optional().describe("Maximum number of nodes to return. Default: 100. Use 0 for unlimited."),
1896
- offset: z10.number().optional().describe("Number of nodes to skip (for pagination). Default: 0."),
1897
- roles: z10.array(z10.string()).optional().describe('Filter to specific roles (e.g., ["button", "link", "textbox"]). Default: all roles.'),
1898
- namedOnly: z10.boolean().optional().describe("Only return nodes with a name/label. Default: true. Filters out anonymous containers.")
1899
- }
1900
- };
1901
- var getAccessibilityTreeTool = async (args) => {
1902
- try {
1903
- const browser = getBrowser();
1904
- if (browser.isAndroid || browser.isIOS) {
1905
- return {
1906
- content: [{
1907
- type: "text",
1908
- text: "Error: get_accessibility is browser-only. For mobile apps, use get_visible_elements instead."
1909
- }]
1910
- };
1911
- }
1912
- const { limit = 100, offset = 0, roles, namedOnly = true } = args || {};
1913
- let nodes = await getBrowserAccessibilityTree(browser);
1914
- if (nodes.length === 0) {
1915
- return {
1916
- content: [{ type: "text", text: "No accessibility tree available" }]
1917
- };
1918
- }
1919
- if (namedOnly) {
1920
- nodes = nodes.filter((n) => n.name && n.name.trim() !== "");
1921
- }
1922
- if (roles && roles.length > 0) {
1923
- const roleSet = new Set(roles.map((r) => r.toLowerCase()));
1924
- nodes = nodes.filter((n) => n.role && roleSet.has(n.role.toLowerCase()));
1925
- }
1926
- const total = nodes.length;
1927
- if (offset > 0) {
1928
- nodes = nodes.slice(offset);
1929
- }
1930
- if (limit > 0) {
1931
- nodes = nodes.slice(0, limit);
1932
- }
1933
- const result = {
1934
- total,
1935
- showing: nodes.length,
1936
- hasMore: offset + nodes.length < total,
1937
- nodes
1938
- };
1939
- const toon = encode2(result).replace(/,""/g, ",").replace(/"",/g, ",");
1940
- return {
1941
- content: [{ type: "text", text: toon }]
1942
- };
1943
- } catch (e) {
1944
- return {
1945
- content: [{ type: "text", text: `Error getting accessibility tree: ${e}` }]
1946
- };
1947
- }
1948
- };
1949
-
1950
2178
  // src/tools/gestures.tool.ts
1951
2179
  import { z as z11 } from "zod";
1952
2180
  var tapElementToolDefinition = {
@@ -2347,7 +2575,7 @@ var package_default = {
2347
2575
  type: "git",
2348
2576
  url: "git://github.com/webdriverio/mcp.git"
2349
2577
  },
2350
- version: "2.1.0",
2578
+ version: "2.2.1",
2351
2579
  description: "MCP server with WebdriverIO for browser and mobile app automation (iOS/Android via Appium)",
2352
2580
  main: "./lib/server.js",
2353
2581
  module: "./lib/server.js",
@@ -2379,19 +2607,21 @@ var package_default = {
2379
2607
  bundle: "tsup && shx chmod +x lib/server.js",
2380
2608
  postbundle: "npm pack",
2381
2609
  lint: "eslint src/ --fix && tsc --noEmit",
2610
+ "lint:tests": "eslint tests/ --fix && tsc -p tsconfig.test.json --noEmit",
2382
2611
  start: "node lib/server.js",
2383
2612
  dev: "tsx --watch src/server.ts",
2384
- prepare: "husky"
2613
+ prepare: "husky",
2614
+ test: "vitest run"
2385
2615
  },
2386
2616
  dependencies: {
2387
- "@modelcontextprotocol/sdk": "1.25",
2388
- xpath: "^0.0.34",
2617
+ "@modelcontextprotocol/sdk": "1.26",
2389
2618
  "@toon-format/toon": "^2.1.0",
2390
2619
  "@wdio/protocols": "^9.16.2",
2391
2620
  "@xmldom/xmldom": "^0.8.11",
2392
2621
  "puppeteer-core": "^24.35.0",
2393
2622
  sharp: "^0.34.5",
2394
2623
  webdriverio: "9.23",
2624
+ xpath: "^0.0.34",
2395
2625
  zod: "^4.3.5"
2396
2626
  },
2397
2627
  devDependencies: {
@@ -2400,13 +2630,15 @@ var package_default = {
2400
2630
  "@wdio/eslint": "^0.1.3",
2401
2631
  "@wdio/types": "^9.20.0",
2402
2632
  eslint: "^9.39.2",
2633
+ "happy-dom": "^20.7.0",
2403
2634
  husky: "^9.1.7",
2404
2635
  "release-it": "^19.2.3",
2405
2636
  rimraf: "^6.1.2",
2406
2637
  shx: "^0.4.0",
2407
2638
  tsup: "^8.5.1",
2408
2639
  tsx: "^4.21.0",
2409
- typescript: "5.9"
2640
+ typescript: "5.9",
2641
+ vitest: "^4.0.18"
2410
2642
  },
2411
2643
  packageManager: "pnpm@10.12.4"
2412
2644
  };