seleniumboot-mcp 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/PKG-INFO +22 -4
  2. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/README.md +21 -3
  3. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/pyproject.toml +1 -1
  4. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/browser_tools.py +1 -0
  5. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/element_tools.py +511 -356
  6. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/PKG-INFO +22 -4
  7. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/setup.cfg +0 -0
  8. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/__init__.py +0 -0
  9. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/server.py +0 -0
  10. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/__init__.py +0 -0
  11. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/_locators.py +0 -0
  12. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/assertion_tools.py +0 -0
  13. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/selenium_mcp/tools/codegen_tools.py +0 -0
  14. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/SOURCES.txt +0 -0
  15. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/dependency_links.txt +0 -0
  16. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/entry_points.txt +0 -0
  17. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/requires.txt +0 -0
  18. {seleniumboot_mcp-0.2.2 → seleniumboot_mcp-0.2.3}/src/seleniumboot_mcp.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seleniumboot-mcp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Python MCP server for Selenium WebDriver — browser automation, Java TestNG/JUnit5/Cucumber codegen, and Page Object Model generation
5
5
  Author-email: Raza Tech <razatechnologyservices@gmail.com>
6
6
  License: MIT
@@ -25,7 +25,7 @@ Requires-Dist: selenium>=4.6.0
25
25
  A Python **Model Context Protocol (MCP)** server for Selenium WebDriver automation.
26
26
  Let Claude or GitHub Copilot control a real browser — navigate pages, interact with elements,
27
27
  run assertions, and generate ready-to-run **Java TestNG / JUnit 5 / Cucumber / pytest** test code from recorded sessions.
28
- 43 tools. No ChromeDriver setup. Browser auto-starts on first use.
28
+ 45 tools. No ChromeDriver setup. Browser auto-starts on first use.
29
29
 
30
30
  [![PyPI](https://img.shields.io/pypi/v/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
31
31
  [![Python](https://img.shields.io/pypi/pyversions/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
@@ -107,7 +107,7 @@ Claude controls the real browser, records every action, and on request generates
107
107
 
108
108
  ---
109
109
 
110
- ## Tools (43 total)
110
+ ## Tools (45 total)
111
111
 
112
112
  ### Browser
113
113
  | Tool | Description |
@@ -143,6 +143,8 @@ Claude controls the real browser, records every action, and on request generates
143
143
  | `wait_for_element` | Wait: visible / clickable / present / invisible |
144
144
  | `scroll_to_element` | Scroll element into view |
145
145
  | `clear_field` | Clear input field |
146
+ | `get_healed_locators` | View all self-healed selector mappings for the session |
147
+ | `clear_healed_locators` | Reset the self-healing cache |
146
148
 
147
149
  ### Assertions
148
150
  | Tool | Description |
@@ -250,6 +252,22 @@ public class LoginSteps {
250
252
 
251
253
  ---
252
254
 
255
+ ## Self-Healing Locators
256
+
257
+ When a selector fails to find an element, seleniumboot-mcp automatically tries alternative strategies before giving up:
258
+
259
+ | Primary selector | Alternatives tried |
260
+ |---|---|
261
+ | `#my-id` (CSS) | `by=id "my-id"`, `[id='my-id']` |
262
+ | `.my-class` (CSS) | `by=class "my-class"`, `[class*='my-class']` |
263
+ | `input[type='email']` (CSS) | `//input[@type='email']` (XPath) |
264
+ | `//button[@id='ok']` (XPath) | `button[id='ok']` (CSS), `by=id "ok"` |
265
+ | `"A, B"` comma list | tries A first, then B |
266
+
267
+ Successful fallbacks are **cached** so the healed selector is reused automatically. Use `get_healed_locators` to inspect the cache and update your test code, and `clear_healed_locators` to start fresh.
268
+
269
+ ---
270
+
253
271
  ## Links
254
272
 
255
273
  - **GitHub:** [github.com/seleniumboot/selenium-mcp](https://github.com/seleniumboot/selenium-mcp)
@@ -266,8 +284,8 @@ public class LoginSteps {
266
284
  - [x] Auto-start browser on first use (no explicit `start_browser` needed)
267
285
  - [x] Page Object Model generation (`generate_java_page_object`)
268
286
  - [x] Cucumber / Gherkin step generation (`generate_gherkin`)
287
+ - [x] Self-healing locators — automatic fallback when a selector breaks
269
288
  - [ ] CI/CD config generator (GitHub Actions, Jenkins)
270
- - [ ] Self-healing locators
271
289
 
272
290
  ---
273
291
 
@@ -3,7 +3,7 @@
3
3
  A Python **Model Context Protocol (MCP)** server for Selenium WebDriver automation.
4
4
  Let Claude or GitHub Copilot control a real browser — navigate pages, interact with elements,
5
5
  run assertions, and generate ready-to-run **Java TestNG / JUnit 5 / Cucumber / pytest** test code from recorded sessions.
6
- 43 tools. No ChromeDriver setup. Browser auto-starts on first use.
6
+ 45 tools. No ChromeDriver setup. Browser auto-starts on first use.
7
7
 
8
8
  [![PyPI](https://img.shields.io/pypi/v/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
9
9
  [![Python](https://img.shields.io/pypi/pyversions/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
@@ -85,7 +85,7 @@ Claude controls the real browser, records every action, and on request generates
85
85
 
86
86
  ---
87
87
 
88
- ## Tools (43 total)
88
+ ## Tools (45 total)
89
89
 
90
90
  ### Browser
91
91
  | Tool | Description |
@@ -121,6 +121,8 @@ Claude controls the real browser, records every action, and on request generates
121
121
  | `wait_for_element` | Wait: visible / clickable / present / invisible |
122
122
  | `scroll_to_element` | Scroll element into view |
123
123
  | `clear_field` | Clear input field |
124
+ | `get_healed_locators` | View all self-healed selector mappings for the session |
125
+ | `clear_healed_locators` | Reset the self-healing cache |
124
126
 
125
127
  ### Assertions
126
128
  | Tool | Description |
@@ -228,6 +230,22 @@ public class LoginSteps {
228
230
 
229
231
  ---
230
232
 
233
+ ## Self-Healing Locators
234
+
235
+ When a selector fails to find an element, seleniumboot-mcp automatically tries alternative strategies before giving up:
236
+
237
+ | Primary selector | Alternatives tried |
238
+ |---|---|
239
+ | `#my-id` (CSS) | `by=id "my-id"`, `[id='my-id']` |
240
+ | `.my-class` (CSS) | `by=class "my-class"`, `[class*='my-class']` |
241
+ | `input[type='email']` (CSS) | `//input[@type='email']` (XPath) |
242
+ | `//button[@id='ok']` (XPath) | `button[id='ok']` (CSS), `by=id "ok"` |
243
+ | `"A, B"` comma list | tries A first, then B |
244
+
245
+ Successful fallbacks are **cached** so the healed selector is reused automatically. Use `get_healed_locators` to inspect the cache and update your test code, and `clear_healed_locators` to start fresh.
246
+
247
+ ---
248
+
231
249
  ## Links
232
250
 
233
251
  - **GitHub:** [github.com/seleniumboot/selenium-mcp](https://github.com/seleniumboot/selenium-mcp)
@@ -244,8 +262,8 @@ public class LoginSteps {
244
262
  - [x] Auto-start browser on first use (no explicit `start_browser` needed)
245
263
  - [x] Page Object Model generation (`generate_java_page_object`)
246
264
  - [x] Cucumber / Gherkin step generation (`generate_gherkin`)
265
+ - [x] Self-healing locators — automatic fallback when a selector breaks
247
266
  - [ ] CI/CD config generator (GitHub Actions, Jenkins)
248
- - [ ] Self-healing locators
249
267
 
250
268
  ---
251
269
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "seleniumboot-mcp"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "A Python MCP server for Selenium WebDriver — browser automation, Java TestNG/JUnit5/Cucumber codegen, and Page Object Model generation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,6 +13,7 @@ class BrowserTools:
13
13
  def __init__(self):
14
14
  self.driver = None
15
15
  self._session_log = [] # records all actions for codegen
16
+ self._healer_cache: dict[str, tuple[str, str]] = {} # (selector,by) → (healed_selector,healed_by)
16
17
 
17
18
  # ------------------------------------------------------------------ #
18
19
  # Session helpers #
@@ -1,356 +1,511 @@
1
- """
2
- Element Tools — find, click, type, select, hover, drag-and-drop, waits
3
- """
4
-
5
- from selenium.webdriver.common.by import By
6
- from selenium.webdriver.common.action_chains import ActionChains
7
- from selenium.webdriver.support.ui import WebDriverWait, Select
8
- from selenium.webdriver.support import expected_conditions as EC
9
- from mcp.types import Tool
10
- from selenium_mcp.tools._locators import BY_MAP
11
-
12
- LOCATOR_SCHEMA = {
13
- "selector": {
14
- "type": "string",
15
- "description": "CSS selector, XPath, or other locator value"
16
- },
17
- "by": {
18
- "type": "string",
19
- "enum": list(BY_MAP.keys()),
20
- "default": "css",
21
- "description": "Locator strategy"
22
- },
23
- "timeout": {
24
- "type": "integer",
25
- "default": 10,
26
- "description": "Wait timeout in seconds"
27
- }
28
- }
29
-
30
-
31
- class ElementTools:
32
- def __init__(self, browser_tools):
33
- self.browser = browser_tools
34
-
35
- def _by(self, strategy: str):
36
- return BY_MAP.get(strategy, By.CSS_SELECTOR)
37
-
38
- def _find(self, selector, by="css", timeout=10):
39
- driver = self.browser.get_driver()
40
- return WebDriverWait(driver, timeout).until(
41
- EC.presence_of_element_located((self._by(by), selector))
42
- )
43
-
44
- def _find_clickable(self, selector, by="css", timeout=10):
45
- driver = self.browser.get_driver()
46
- return WebDriverWait(driver, timeout).until(
47
- EC.element_to_be_clickable((self._by(by), selector))
48
- )
49
-
50
- def get_tools(self) -> list[Tool]:
51
- return [
52
- Tool(
53
- name="find_element",
54
- description="Find an element and return its text, tag, and attributes.",
55
- inputSchema={
56
- "type": "object",
57
- "properties": LOCATOR_SCHEMA,
58
- "required": ["selector"],
59
- },
60
- ),
61
- Tool(
62
- name="find_elements",
63
- description="Find all matching elements and return their texts.",
64
- inputSchema={
65
- "type": "object",
66
- "properties": LOCATOR_SCHEMA,
67
- "required": ["selector"],
68
- },
69
- ),
70
- Tool(
71
- name="click",
72
- description="Click on an element.",
73
- inputSchema={
74
- "type": "object",
75
- "properties": LOCATOR_SCHEMA,
76
- "required": ["selector"],
77
- },
78
- ),
79
- Tool(
80
- name="type_text",
81
- description="Clear and type text into an input field.",
82
- inputSchema={
83
- "type": "object",
84
- "properties": {
85
- **LOCATOR_SCHEMA,
86
- "text": {"type": "string", "description": "Text to type"},
87
- "clear_first": {"type": "boolean", "default": True}
88
- },
89
- "required": ["selector", "text"],
90
- },
91
- ),
92
- Tool(
93
- name="get_text",
94
- description="Get the visible text content of an element.",
95
- inputSchema={
96
- "type": "object",
97
- "properties": LOCATOR_SCHEMA,
98
- "required": ["selector"],
99
- },
100
- ),
101
- Tool(
102
- name="get_attribute",
103
- description="Get an attribute value from an element.",
104
- inputSchema={
105
- "type": "object",
106
- "properties": {
107
- **LOCATOR_SCHEMA,
108
- "attribute": {"type": "string", "description": "Attribute name e.g. href, value, class"}
109
- },
110
- "required": ["selector", "attribute"],
111
- },
112
- ),
113
- Tool(
114
- name="select_option",
115
- description="Select an option from a <select> dropdown by visible text, value, or index.",
116
- inputSchema={
117
- "type": "object",
118
- "properties": {
119
- **LOCATOR_SCHEMA,
120
- "by_text": {"type": "string"},
121
- "by_value": {"type": "string"},
122
- "by_index": {"type": "integer"},
123
- },
124
- "required": ["selector"],
125
- },
126
- ),
127
- Tool(
128
- name="hover",
129
- description="Hover the mouse over an element.",
130
- inputSchema={
131
- "type": "object",
132
- "properties": LOCATOR_SCHEMA,
133
- "required": ["selector"],
134
- },
135
- ),
136
- Tool(
137
- name="double_click",
138
- description="Double-click on an element.",
139
- inputSchema={
140
- "type": "object",
141
- "properties": LOCATOR_SCHEMA,
142
- "required": ["selector"],
143
- },
144
- ),
145
- Tool(
146
- name="right_click",
147
- description="Right-click (context menu) on an element.",
148
- inputSchema={
149
- "type": "object",
150
- "properties": LOCATOR_SCHEMA,
151
- "required": ["selector"],
152
- },
153
- ),
154
- Tool(
155
- name="drag_and_drop",
156
- description="Drag one element and drop it onto another.",
157
- inputSchema={
158
- "type": "object",
159
- "properties": {
160
- "source_selector": {"type": "string"},
161
- "target_selector": {"type": "string"},
162
- "by": {"type": "string", "default": "css"},
163
- },
164
- "required": ["source_selector", "target_selector"],
165
- },
166
- ),
167
- Tool(
168
- name="is_displayed",
169
- description="Check if an element is visible on the page.",
170
- inputSchema={
171
- "type": "object",
172
- "properties": LOCATOR_SCHEMA,
173
- "required": ["selector"],
174
- },
175
- ),
176
- Tool(
177
- name="is_enabled",
178
- description="Check if an element is enabled (not disabled).",
179
- inputSchema={
180
- "type": "object",
181
- "properties": LOCATOR_SCHEMA,
182
- "required": ["selector"],
183
- },
184
- ),
185
- Tool(
186
- name="wait_for_element",
187
- description="Wait until an element is visible on the page.",
188
- inputSchema={
189
- "type": "object",
190
- "properties": {
191
- **LOCATOR_SCHEMA,
192
- "condition": {
193
- "type": "string",
194
- "enum": ["visible", "clickable", "present", "invisible"],
195
- "default": "visible"
196
- }
197
- },
198
- "required": ["selector"],
199
- },
200
- ),
201
- Tool(
202
- name="scroll_to_element",
203
- description="Scroll the page to bring an element into view.",
204
- inputSchema={
205
- "type": "object",
206
- "properties": LOCATOR_SCHEMA,
207
- "required": ["selector"],
208
- },
209
- ),
210
- Tool(
211
- name="clear_field",
212
- description="Clear the text in an input field.",
213
- inputSchema={
214
- "type": "object",
215
- "properties": LOCATOR_SCHEMA,
216
- "required": ["selector"],
217
- },
218
- ),
219
- ]
220
-
221
- def get_handlers(self) -> dict:
222
- return {
223
- "find_element": self._find_element,
224
- "find_elements": self._find_elements,
225
- "click": self._click,
226
- "type_text": self._type_text,
227
- "get_text": self._get_text,
228
- "get_attribute": self._get_attribute,
229
- "select_option": self._select_option,
230
- "hover": self._hover,
231
- "double_click": self._double_click,
232
- "right_click": self._right_click,
233
- "drag_and_drop": self._drag_and_drop,
234
- "is_displayed": self._is_displayed,
235
- "is_enabled": self._is_enabled,
236
- "wait_for_element":self._wait_for_element,
237
- "scroll_to_element":self._scroll_to_element,
238
- "clear_field": self._clear_field,
239
- }
240
-
241
- # ------------------------------------------------------------------ #
242
- # Handlers #
243
- # ------------------------------------------------------------------ #
244
- async def _find_element(self, args: dict) -> str:
245
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
246
- return f"tag={el.tag_name} | text='{el.text}' | displayed={el.is_displayed()}"
247
-
248
- async def _find_elements(self, args: dict) -> str:
249
- driver = self.browser.get_driver()
250
- els = driver.find_elements(self._by(args.get("by", "css")), args["selector"])
251
- results = [f"[{i}] tag={e.tag_name} text='{e.text[:60]}'" for i, e in enumerate(els)]
252
- return f"Found {len(els)} element(s):\n" + "\n".join(results)
253
-
254
- async def _click(self, args: dict) -> str:
255
- el = self._find_clickable(args["selector"], args.get("by", "css"), args.get("timeout", 10))
256
- el.click()
257
- self.browser.record("click", selector=args["selector"], by=args.get("by", "css"))
258
- return f" Clicked '{args['selector']}'"
259
-
260
- async def _type_text(self, args: dict) -> str:
261
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
262
- if args.get("clear_first", True):
263
- el.clear()
264
- el.send_keys(args["text"])
265
- sel_lower = args["selector"].lower()
266
- logged_text = "***" if any(k in sel_lower for k in ("password", "passwd", "pwd")) else args["text"]
267
- self.browser.record("type_text", selector=args["selector"], by=args.get("by", "css"), text=logged_text)
268
- return f"✅ Typed into '{args['selector']}'"
269
-
270
- async def _get_text(self, args: dict) -> str:
271
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
272
- return el.text
273
-
274
- async def _get_attribute(self, args: dict) -> str:
275
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
276
- val = el.get_attribute(args["attribute"])
277
- return str(val)
278
-
279
- async def _select_option(self, args: dict) -> str:
280
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
281
- sel = Select(el)
282
- if "by_text" in args:
283
- sel.select_by_visible_text(args["by_text"])
284
- self.browser.record("select_option", selector=args["selector"], by_text=args["by_text"])
285
- return f"✅ Selected by text: '{args['by_text']}'"
286
- elif "by_value" in args:
287
- sel.select_by_value(args["by_value"])
288
- self.browser.record("select_option", selector=args["selector"], by_value=args["by_value"])
289
- return f"✅ Selected by value: '{args['by_value']}'"
290
- elif "by_index" in args:
291
- sel.select_by_index(args["by_index"])
292
- self.browser.record("select_option", selector=args["selector"], by_index=args["by_index"])
293
- return f"✅ Selected by index: {args['by_index']}"
294
- return " Provide by_text, by_value, or by_index"
295
-
296
- async def _hover(self, args: dict) -> str:
297
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
298
- ActionChains(self.browser.get_driver()).move_to_element(el).perform()
299
- self.browser.record("hover", selector=args["selector"], by=args.get("by", "css"))
300
- return f"✅ Hovered over '{args['selector']}'"
301
-
302
- async def _double_click(self, args: dict) -> str:
303
- el = self._find_clickable(args["selector"], args.get("by", "css"), args.get("timeout", 10))
304
- ActionChains(self.browser.get_driver()).double_click(el).perform()
305
- self.browser.record("double_click", selector=args["selector"], by=args.get("by", "css"))
306
- return f" Double-clicked '{args['selector']}'"
307
-
308
- async def _right_click(self, args: dict) -> str:
309
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
310
- ActionChains(self.browser.get_driver()).context_click(el).perform()
311
- self.browser.record("right_click", selector=args["selector"], by=args.get("by", "css"))
312
- return f" Right-clicked '{args['selector']}'"
313
-
314
- async def _drag_and_drop(self, args: dict) -> str:
315
- by = self._by(args.get("by", "css"))
316
- driver = self.browser.get_driver()
317
- timeout = args.get("timeout", 10)
318
- src = WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, args["source_selector"])))
319
- tgt = WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, args["target_selector"])))
320
- ActionChains(driver).drag_and_drop(src, tgt).perform()
321
- return f"✅ Dragged '{args['source_selector']}' → '{args['target_selector']}'"
322
-
323
- async def _is_displayed(self, args: dict) -> str:
324
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
325
- return str(el.is_displayed())
326
-
327
- async def _is_enabled(self, args: dict) -> str:
328
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
329
- return str(el.is_enabled())
330
-
331
- async def _wait_for_element(self, args: dict) -> str:
332
- driver = self.browser.get_driver()
333
- by = self._by(args.get("by", "css"))
334
- sel = args["selector"]
335
- timeout = args.get("timeout", 10)
336
- condition = args.get("condition", "visible")
337
-
338
- cond_map = {
339
- "visible": EC.visibility_of_element_located,
340
- "clickable": EC.element_to_be_clickable,
341
- "present": EC.presence_of_element_located,
342
- "invisible": EC.invisibility_of_element_located,
343
- }
344
- WebDriverWait(driver, timeout).until(cond_map[condition]((by, sel)))
345
- return f"✅ Element '{sel}' is {condition}"
346
-
347
- async def _scroll_to_element(self, args: dict) -> str:
348
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
349
- self.browser.get_driver().execute_script("arguments[0].scrollIntoView(true);", el)
350
- self.browser.record("scroll_to_element", selector=args["selector"], by=args.get("by", "css"))
351
- return f" Scrolled to '{args['selector']}'"
352
-
353
- async def _clear_field(self, args: dict) -> str:
354
- el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
355
- el.clear()
356
- return f"✅ Cleared '{args['selector']}'"
1
+ """
2
+ Element Tools — find, click, type, select, hover, drag-and-drop, waits
3
+ Includes self-healing locators: when a primary selector fails, alternative
4
+ strategies are tried automatically and the successful one is cached.
5
+ """
6
+
7
+ from selenium.webdriver.common.by import By
8
+ from selenium.webdriver.common.action_chains import ActionChains
9
+ from selenium.webdriver.support.ui import WebDriverWait, Select
10
+ from selenium.webdriver.support import expected_conditions as EC
11
+ from selenium.common.exceptions import TimeoutException, NoSuchElementException
12
+ from mcp.types import Tool
13
+ from selenium_mcp.tools._locators import BY_MAP
14
+
15
+ LOCATOR_SCHEMA = {
16
+ "selector": {
17
+ "type": "string",
18
+ "description": "CSS selector, XPath, or other locator value"
19
+ },
20
+ "by": {
21
+ "type": "string",
22
+ "enum": list(BY_MAP.keys()),
23
+ "default": "css",
24
+ "description": "Locator strategy"
25
+ },
26
+ "timeout": {
27
+ "type": "integer",
28
+ "default": 10,
29
+ "description": "Wait timeout in seconds"
30
+ }
31
+ }
32
+
33
+
34
+ class ElementTools:
35
+ def __init__(self, browser_tools):
36
+ self.browser = browser_tools
37
+ self._last_heal: tuple[str, str, str, str] | None = None # (orig_sel, orig_by, new_sel, new_by)
38
+
39
+ def _by(self, strategy: str):
40
+ return BY_MAP.get(strategy, By.CSS_SELECTOR)
41
+
42
+ # ------------------------------------------------------------------ #
43
+ # Self-healing core #
44
+ # ------------------------------------------------------------------ #
45
+
46
+ def _alternatives(self, selector: str, by: str) -> list[tuple[str, str]]:
47
+ """Generate alternative (selector, by) pairs to try when primary fails."""
48
+ alts: list[tuple[str, str]] = []
49
+
50
+ if by == "css":
51
+ # Multi-selector fallback: split on top-level commas
52
+ parts = [p.strip() for p in selector.split(",") if p.strip()]
53
+ if len(parts) > 1:
54
+ alts.extend((p, "css") for p in parts)
55
+
56
+ # #id → by ID
57
+ if selector.startswith("#") and " " not in selector:
58
+ alts.append((selector[1:], "id"))
59
+
60
+ # .class → by class name (simple single class only)
61
+ if selector.startswith(".") and " " not in selector and "." not in selector[1:]:
62
+ cls = selector[1:]
63
+ alts.append((cls, "class"))
64
+ alts.append((f"[class*='{cls}']", "css"))
65
+
66
+ # tag[attr='val'] → XPath
67
+ if "[" in selector and not selector.startswith("["):
68
+ tag = selector.split("[")[0]
69
+ rest = selector[len(tag):]
70
+ xpath = f"//{tag}{rest.replace('[', '[@').replace('=', '=').replace(']', ']')}"
71
+ alts.append((xpath, "xpath"))
72
+
73
+ # Strip CSS pseudo-classes/elements like :not(...), :first-child, ::before
74
+ import re
75
+ stripped = re.sub(r':{1,2}[\w-]+(\([^)]*\))?', '', selector).strip()
76
+ if stripped and stripped != selector:
77
+ alts.append((stripped, "css"))
78
+
79
+ # Try XPath contains on text if it looks like a label
80
+ if not any(c in selector for c in ("#", ".", "[", ":")):
81
+ alts.append((f"//*[contains(text(),'{selector}')]", "xpath"))
82
+
83
+ elif by == "xpath":
84
+ # Try without axis prefix variations
85
+ if selector.startswith("//"):
86
+ alts.append((selector.lstrip("/"), "xpath"))
87
+ # Try CSS conversion for simple tag[@attr] xpaths
88
+ import re
89
+ m = re.match(r'^//(\w+)\[@(\w+)=[\'"]([^\'"]+)[\'"]\]$', selector)
90
+ if m:
91
+ tag, attr, val = m.groups()
92
+ alts.append((f"{tag}[{attr}='{val}']", "css"))
93
+ if attr == "id":
94
+ alts.append((val, "id"))
95
+ elif attr == "name":
96
+ alts.append((val, "name"))
97
+
98
+ elif by == "id":
99
+ alts.append((f"#{selector}", "css"))
100
+ alts.append((f"[id='{selector}']", "css"))
101
+ alts.append((f"//*[@id='{selector}']", "xpath"))
102
+
103
+ elif by == "name":
104
+ alts.append((f"[name='{selector}']", "css"))
105
+ alts.append((f"//*[@name='{selector}']", "xpath"))
106
+
107
+ return alts
108
+
109
+ def _locate(self, selector: str, by: str, timeout: int, condition_fn=None):
110
+ """
111
+ Find an element using primary selector, falling back to alternatives.
112
+ Caches successful healed selectors via browser._healer_cache.
113
+ Sets self._last_heal when a fallback was used.
114
+ """
115
+ self._last_heal = None
116
+ driver = self.browser.get_driver()
117
+ cache_key = (selector, by)
118
+
119
+ # Check if we already have a healed locator for this selector
120
+ if cache_key in self.browser._healer_cache:
121
+ healed_sel, healed_by = self.browser._healer_cache[cache_key]
122
+ by_val = self._by(healed_by)
123
+ cond = condition_fn or EC.presence_of_element_located
124
+ try:
125
+ el = WebDriverWait(driver, timeout).until(cond((by_val, healed_sel)))
126
+ self._last_heal = (selector, by, healed_sel, healed_by)
127
+ return el
128
+ except (TimeoutException, NoSuchElementException):
129
+ # Cached heal no longer works — invalidate and retry fresh
130
+ del self.browser._healer_cache[cache_key]
131
+
132
+ # Try primary
133
+ by_val = self._by(by)
134
+ cond = condition_fn or EC.presence_of_element_located
135
+ original_exc = None
136
+ try:
137
+ return WebDriverWait(driver, timeout).until(cond((by_val, selector)))
138
+ except (TimeoutException, NoSuchElementException) as e:
139
+ original_exc = e
140
+
141
+ # Primary failed — try alternatives with a short timeout
142
+ for alt_sel, alt_by in self._alternatives(selector, by):
143
+ try:
144
+ alt_by_val = self._by(alt_by)
145
+ el = WebDriverWait(driver, 3).until(cond((alt_by_val, alt_sel)))
146
+ # Cache the successful alternative
147
+ self.browser._healer_cache[cache_key] = (alt_sel, alt_by)
148
+ self._last_heal = (selector, by, alt_sel, alt_by)
149
+ return el
150
+ except (TimeoutException, NoSuchElementException):
151
+ continue
152
+
153
+ raise original_exc
154
+
155
+ def _heal_note(self) -> str:
156
+ if self._last_heal:
157
+ orig_sel, orig_by, new_sel, new_by = self._last_heal
158
+ return f" [⚕ healed: '{orig_sel}' ({orig_by}) → '{new_sel}' ({new_by})]"
159
+ return ""
160
+
161
+ # ------------------------------------------------------------------ #
162
+ # Legacy helpers kept for drag_and_drop (needs two separate locates) #
163
+ # ------------------------------------------------------------------ #
164
+
165
+ def _find(self, selector, by="css", timeout=10):
166
+ return self._locate(selector, by, timeout, EC.presence_of_element_located)
167
+
168
+ def _find_clickable(self, selector, by="css", timeout=10):
169
+ return self._locate(selector, by, timeout, EC.element_to_be_clickable)
170
+
171
+ # ------------------------------------------------------------------ #
172
+ # MCP tool definitions #
173
+ # ------------------------------------------------------------------ #
174
+
175
+ def get_tools(self) -> list[Tool]:
176
+ return [
177
+ Tool(
178
+ name="find_element",
179
+ description="Find an element and return its text, tag, and attributes.",
180
+ inputSchema={
181
+ "type": "object",
182
+ "properties": LOCATOR_SCHEMA,
183
+ "required": ["selector"],
184
+ },
185
+ ),
186
+ Tool(
187
+ name="find_elements",
188
+ description="Find all matching elements and return their texts.",
189
+ inputSchema={
190
+ "type": "object",
191
+ "properties": LOCATOR_SCHEMA,
192
+ "required": ["selector"],
193
+ },
194
+ ),
195
+ Tool(
196
+ name="click",
197
+ description="Click on an element.",
198
+ inputSchema={
199
+ "type": "object",
200
+ "properties": LOCATOR_SCHEMA,
201
+ "required": ["selector"],
202
+ },
203
+ ),
204
+ Tool(
205
+ name="type_text",
206
+ description="Clear and type text into an input field.",
207
+ inputSchema={
208
+ "type": "object",
209
+ "properties": {
210
+ **LOCATOR_SCHEMA,
211
+ "text": {"type": "string", "description": "Text to type"},
212
+ "clear_first": {"type": "boolean", "default": True}
213
+ },
214
+ "required": ["selector", "text"],
215
+ },
216
+ ),
217
+ Tool(
218
+ name="get_text",
219
+ description="Get the visible text content of an element.",
220
+ inputSchema={
221
+ "type": "object",
222
+ "properties": LOCATOR_SCHEMA,
223
+ "required": ["selector"],
224
+ },
225
+ ),
226
+ Tool(
227
+ name="get_attribute",
228
+ description="Get an attribute value from an element.",
229
+ inputSchema={
230
+ "type": "object",
231
+ "properties": {
232
+ **LOCATOR_SCHEMA,
233
+ "attribute": {"type": "string", "description": "Attribute name e.g. href, value, class"}
234
+ },
235
+ "required": ["selector", "attribute"],
236
+ },
237
+ ),
238
+ Tool(
239
+ name="select_option",
240
+ description="Select an option from a <select> dropdown by visible text, value, or index.",
241
+ inputSchema={
242
+ "type": "object",
243
+ "properties": {
244
+ **LOCATOR_SCHEMA,
245
+ "by_text": {"type": "string"},
246
+ "by_value": {"type": "string"},
247
+ "by_index": {"type": "integer"},
248
+ },
249
+ "required": ["selector"],
250
+ },
251
+ ),
252
+ Tool(
253
+ name="hover",
254
+ description="Hover the mouse over an element.",
255
+ inputSchema={
256
+ "type": "object",
257
+ "properties": LOCATOR_SCHEMA,
258
+ "required": ["selector"],
259
+ },
260
+ ),
261
+ Tool(
262
+ name="double_click",
263
+ description="Double-click on an element.",
264
+ inputSchema={
265
+ "type": "object",
266
+ "properties": LOCATOR_SCHEMA,
267
+ "required": ["selector"],
268
+ },
269
+ ),
270
+ Tool(
271
+ name="right_click",
272
+ description="Right-click (context menu) on an element.",
273
+ inputSchema={
274
+ "type": "object",
275
+ "properties": LOCATOR_SCHEMA,
276
+ "required": ["selector"],
277
+ },
278
+ ),
279
+ Tool(
280
+ name="drag_and_drop",
281
+ description="Drag one element and drop it onto another.",
282
+ inputSchema={
283
+ "type": "object",
284
+ "properties": {
285
+ "source_selector": {"type": "string"},
286
+ "target_selector": {"type": "string"},
287
+ "by": {"type": "string", "default": "css"},
288
+ },
289
+ "required": ["source_selector", "target_selector"],
290
+ },
291
+ ),
292
+ Tool(
293
+ name="is_displayed",
294
+ description="Check if an element is visible on the page.",
295
+ inputSchema={
296
+ "type": "object",
297
+ "properties": LOCATOR_SCHEMA,
298
+ "required": ["selector"],
299
+ },
300
+ ),
301
+ Tool(
302
+ name="is_enabled",
303
+ description="Check if an element is enabled (not disabled).",
304
+ inputSchema={
305
+ "type": "object",
306
+ "properties": LOCATOR_SCHEMA,
307
+ "required": ["selector"],
308
+ },
309
+ ),
310
+ Tool(
311
+ name="wait_for_element",
312
+ description="Wait until an element is visible on the page.",
313
+ inputSchema={
314
+ "type": "object",
315
+ "properties": {
316
+ **LOCATOR_SCHEMA,
317
+ "condition": {
318
+ "type": "string",
319
+ "enum": ["visible", "clickable", "present", "invisible"],
320
+ "default": "visible"
321
+ }
322
+ },
323
+ "required": ["selector"],
324
+ },
325
+ ),
326
+ Tool(
327
+ name="scroll_to_element",
328
+ description="Scroll the page to bring an element into view.",
329
+ inputSchema={
330
+ "type": "object",
331
+ "properties": LOCATOR_SCHEMA,
332
+ "required": ["selector"],
333
+ },
334
+ ),
335
+ Tool(
336
+ name="clear_field",
337
+ description="Clear the text in an input field.",
338
+ inputSchema={
339
+ "type": "object",
340
+ "properties": LOCATOR_SCHEMA,
341
+ "required": ["selector"],
342
+ },
343
+ ),
344
+ Tool(
345
+ name="get_healed_locators",
346
+ description="Return all self-healed locator mappings from this session. Shows which selectors were automatically repaired and what they were replaced with.",
347
+ inputSchema={"type": "object", "properties": {}},
348
+ ),
349
+ Tool(
350
+ name="clear_healed_locators",
351
+ description="Clear the self-healing locator cache so all selectors are re-evaluated from scratch.",
352
+ inputSchema={"type": "object", "properties": {}},
353
+ ),
354
+ ]
355
+
356
+ def get_handlers(self) -> dict:
357
+ return {
358
+ "find_element": self._find_element,
359
+ "find_elements": self._find_elements,
360
+ "click": self._click,
361
+ "type_text": self._type_text,
362
+ "get_text": self._get_text,
363
+ "get_attribute": self._get_attribute,
364
+ "select_option": self._select_option,
365
+ "hover": self._hover,
366
+ "double_click": self._double_click,
367
+ "right_click": self._right_click,
368
+ "drag_and_drop": self._drag_and_drop,
369
+ "is_displayed": self._is_displayed,
370
+ "is_enabled": self._is_enabled,
371
+ "wait_for_element": self._wait_for_element,
372
+ "scroll_to_element": self._scroll_to_element,
373
+ "clear_field": self._clear_field,
374
+ "get_healed_locators": self._get_healed_locators,
375
+ "clear_healed_locators":self._clear_healed_locators,
376
+ }
377
+
378
+ # ------------------------------------------------------------------ #
379
+ # Handlers #
380
+ # ------------------------------------------------------------------ #
381
+
382
+ async def _find_element(self, args: dict) -> str:
383
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
384
+ return f"tag={el.tag_name} | text='{el.text}' | displayed={el.is_displayed()}{self._heal_note()}"
385
+
386
+ async def _find_elements(self, args: dict) -> str:
387
+ driver = self.browser.get_driver()
388
+ els = driver.find_elements(self._by(args.get("by", "css")), args["selector"])
389
+ results = [f"[{i}] tag={e.tag_name} text='{e.text[:60]}'" for i, e in enumerate(els)]
390
+ return f"Found {len(els)} element(s):\n" + "\n".join(results)
391
+
392
+ async def _click(self, args: dict) -> str:
393
+ el = self._find_clickable(args["selector"], args.get("by", "css"), args.get("timeout", 10))
394
+ el.click()
395
+ self.browser.record("click", selector=args["selector"], by=args.get("by", "css"))
396
+ return f"✅ Clicked '{args['selector']}'{self._heal_note()}"
397
+
398
+ async def _type_text(self, args: dict) -> str:
399
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
400
+ if args.get("clear_first", True):
401
+ el.clear()
402
+ el.send_keys(args["text"])
403
+ sel_lower = args["selector"].lower()
404
+ logged_text = "***" if any(k in sel_lower for k in ("password", "passwd", "pwd")) else args["text"]
405
+ self.browser.record("type_text", selector=args["selector"], by=args.get("by", "css"), text=logged_text)
406
+ return f"✅ Typed into '{args['selector']}'{self._heal_note()}"
407
+
408
+ async def _get_text(self, args: dict) -> str:
409
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
410
+ note = self._heal_note()
411
+ return el.text + note if note else el.text
412
+
413
+ async def _get_attribute(self, args: dict) -> str:
414
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
415
+ val = el.get_attribute(args["attribute"])
416
+ note = self._heal_note()
417
+ return str(val) + note if note else str(val)
418
+
419
+ async def _select_option(self, args: dict) -> str:
420
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
421
+ sel = Select(el)
422
+ note = self._heal_note()
423
+ if "by_text" in args:
424
+ sel.select_by_visible_text(args["by_text"])
425
+ self.browser.record("select_option", selector=args["selector"], by_text=args["by_text"])
426
+ return f"✅ Selected by text: '{args['by_text']}'{note}"
427
+ elif "by_value" in args:
428
+ sel.select_by_value(args["by_value"])
429
+ self.browser.record("select_option", selector=args["selector"], by_value=args["by_value"])
430
+ return f"✅ Selected by value: '{args['by_value']}'{note}"
431
+ elif "by_index" in args:
432
+ sel.select_by_index(args["by_index"])
433
+ self.browser.record("select_option", selector=args["selector"], by_index=args["by_index"])
434
+ return f"✅ Selected by index: {args['by_index']}{note}"
435
+ return "❌ Provide by_text, by_value, or by_index"
436
+
437
+ async def _hover(self, args: dict) -> str:
438
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
439
+ ActionChains(self.browser.get_driver()).move_to_element(el).perform()
440
+ self.browser.record("hover", selector=args["selector"], by=args.get("by", "css"))
441
+ return f"✅ Hovered over '{args['selector']}'{self._heal_note()}"
442
+
443
+ async def _double_click(self, args: dict) -> str:
444
+ el = self._find_clickable(args["selector"], args.get("by", "css"), args.get("timeout", 10))
445
+ ActionChains(self.browser.get_driver()).double_click(el).perform()
446
+ self.browser.record("double_click", selector=args["selector"], by=args.get("by", "css"))
447
+ return f"✅ Double-clicked '{args['selector']}'{self._heal_note()}"
448
+
449
+ async def _right_click(self, args: dict) -> str:
450
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
451
+ ActionChains(self.browser.get_driver()).context_click(el).perform()
452
+ self.browser.record("right_click", selector=args["selector"], by=args.get("by", "css"))
453
+ return f"✅ Right-clicked '{args['selector']}'{self._heal_note()}"
454
+
455
+ async def _drag_and_drop(self, args: dict) -> str:
456
+ by = self._by(args.get("by", "css"))
457
+ driver = self.browser.get_driver()
458
+ timeout = args.get("timeout", 10)
459
+ src = WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, args["source_selector"])))
460
+ tgt = WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, args["target_selector"])))
461
+ ActionChains(driver).drag_and_drop(src, tgt).perform()
462
+ return f"✅ Dragged '{args['source_selector']}' → '{args['target_selector']}'"
463
+
464
+ async def _is_displayed(self, args: dict) -> str:
465
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
466
+ return str(el.is_displayed()) + self._heal_note()
467
+
468
+ async def _is_enabled(self, args: dict) -> str:
469
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
470
+ return str(el.is_enabled()) + self._heal_note()
471
+
472
+ async def _wait_for_element(self, args: dict) -> str:
473
+ driver = self.browser.get_driver()
474
+ by = self._by(args.get("by", "css"))
475
+ sel = args["selector"]
476
+ timeout = args.get("timeout", 10)
477
+ condition = args.get("condition", "visible")
478
+
479
+ cond_map = {
480
+ "visible": EC.visibility_of_element_located,
481
+ "clickable": EC.element_to_be_clickable,
482
+ "present": EC.presence_of_element_located,
483
+ "invisible": EC.invisibility_of_element_located,
484
+ }
485
+ WebDriverWait(driver, timeout).until(cond_map[condition]((by, sel)))
486
+ return f"✅ Element '{sel}' is {condition}"
487
+
488
+ async def _scroll_to_element(self, args: dict) -> str:
489
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
490
+ self.browser.get_driver().execute_script("arguments[0].scrollIntoView(true);", el)
491
+ self.browser.record("scroll_to_element", selector=args["selector"], by=args.get("by", "css"))
492
+ return f"✅ Scrolled to '{args['selector']}'{self._heal_note()}"
493
+
494
+ async def _clear_field(self, args: dict) -> str:
495
+ el = self._find(args["selector"], args.get("by", "css"), args.get("timeout", 10))
496
+ el.clear()
497
+ return f"✅ Cleared '{args['selector']}'{self._heal_note()}"
498
+
499
+ async def _get_healed_locators(self, args: dict) -> str:
500
+ cache = self.browser._healer_cache
501
+ if not cache:
502
+ return "No healed locators in this session."
503
+ lines = ["Healed locators this session:"]
504
+ for (orig_sel, orig_by), (new_sel, new_by) in cache.items():
505
+ lines.append(f" [{orig_by}] '{orig_sel}' → [{new_by}] '{new_sel}'")
506
+ return "\n".join(lines)
507
+
508
+ async def _clear_healed_locators(self, args: dict) -> str:
509
+ count = len(self.browser._healer_cache)
510
+ self.browser._healer_cache.clear()
511
+ return f"✅ Cleared {count} healed locator(s) from cache."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seleniumboot-mcp
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A Python MCP server for Selenium WebDriver — browser automation, Java TestNG/JUnit5/Cucumber codegen, and Page Object Model generation
5
5
  Author-email: Raza Tech <razatechnologyservices@gmail.com>
6
6
  License: MIT
@@ -25,7 +25,7 @@ Requires-Dist: selenium>=4.6.0
25
25
  A Python **Model Context Protocol (MCP)** server for Selenium WebDriver automation.
26
26
  Let Claude or GitHub Copilot control a real browser — navigate pages, interact with elements,
27
27
  run assertions, and generate ready-to-run **Java TestNG / JUnit 5 / Cucumber / pytest** test code from recorded sessions.
28
- 43 tools. No ChromeDriver setup. Browser auto-starts on first use.
28
+ 45 tools. No ChromeDriver setup. Browser auto-starts on first use.
29
29
 
30
30
  [![PyPI](https://img.shields.io/pypi/v/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
31
31
  [![Python](https://img.shields.io/pypi/pyversions/seleniumboot-mcp)](https://pypi.org/project/seleniumboot-mcp/)
@@ -107,7 +107,7 @@ Claude controls the real browser, records every action, and on request generates
107
107
 
108
108
  ---
109
109
 
110
- ## Tools (43 total)
110
+ ## Tools (45 total)
111
111
 
112
112
  ### Browser
113
113
  | Tool | Description |
@@ -143,6 +143,8 @@ Claude controls the real browser, records every action, and on request generates
143
143
  | `wait_for_element` | Wait: visible / clickable / present / invisible |
144
144
  | `scroll_to_element` | Scroll element into view |
145
145
  | `clear_field` | Clear input field |
146
+ | `get_healed_locators` | View all self-healed selector mappings for the session |
147
+ | `clear_healed_locators` | Reset the self-healing cache |
146
148
 
147
149
  ### Assertions
148
150
  | Tool | Description |
@@ -250,6 +252,22 @@ public class LoginSteps {
250
252
 
251
253
  ---
252
254
 
255
+ ## Self-Healing Locators
256
+
257
+ When a selector fails to find an element, seleniumboot-mcp automatically tries alternative strategies before giving up:
258
+
259
+ | Primary selector | Alternatives tried |
260
+ |---|---|
261
+ | `#my-id` (CSS) | `by=id "my-id"`, `[id='my-id']` |
262
+ | `.my-class` (CSS) | `by=class "my-class"`, `[class*='my-class']` |
263
+ | `input[type='email']` (CSS) | `//input[@type='email']` (XPath) |
264
+ | `//button[@id='ok']` (XPath) | `button[id='ok']` (CSS), `by=id "ok"` |
265
+ | `"A, B"` comma list | tries A first, then B |
266
+
267
+ Successful fallbacks are **cached** so the healed selector is reused automatically. Use `get_healed_locators` to inspect the cache and update your test code, and `clear_healed_locators` to start fresh.
268
+
269
+ ---
270
+
253
271
  ## Links
254
272
 
255
273
  - **GitHub:** [github.com/seleniumboot/selenium-mcp](https://github.com/seleniumboot/selenium-mcp)
@@ -266,8 +284,8 @@ public class LoginSteps {
266
284
  - [x] Auto-start browser on first use (no explicit `start_browser` needed)
267
285
  - [x] Page Object Model generation (`generate_java_page_object`)
268
286
  - [x] Cucumber / Gherkin step generation (`generate_gherkin`)
287
+ - [x] Self-healing locators — automatic fallback when a selector breaks
269
288
  - [ ] CI/CD config generator (GitHub Actions, Jenkins)
270
- - [ ] Self-healing locators
271
289
 
272
290
  ---
273
291