selenium-python-ai-agent 0.2.0__py3-none-any.whl
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.
- selenium_agent/__init__.py +20 -0
- selenium_agent/agents/__init__.py +5 -0
- selenium_agent/agents/coder.py +505 -0
- selenium_agent/agents/definitions.py +152 -0
- selenium_agent/agents/healer.py +638 -0
- selenium_agent/agents/planner.py +279 -0
- selenium_agent/bdd/__init__.py +15 -0
- selenium_agent/bdd/gherkin_advisor.py +189 -0
- selenium_agent/bdd/templates.py +98 -0
- selenium_agent/cli.py +314 -0
- selenium_agent/core/__init__.py +3 -0
- selenium_agent/core/orchestrator.py +189 -0
- selenium_agent/scanner/__init__.py +3 -0
- selenium_agent/scanner/project_scanner.py +452 -0
- selenium_agent/selenium/__init__.py +6 -0
- selenium_agent/selenium/base_page.py +447 -0
- selenium_agent/selenium/driver_factory.py +222 -0
- selenium_agent/selenium/error_map.py +235 -0
- selenium_agent/selenium/locator_advisor.py +247 -0
- selenium_agent/selenium/locator_scanner.py +502 -0
- selenium_agent/utils/__init__.py +26 -0
- selenium_agent/utils/code_validator.py +96 -0
- selenium_agent/utils/config_manager.py +81 -0
- selenium_agent/utils/json_utils.py +103 -0
- selenium_agent/utils/llm.py +240 -0
- selenium_agent/utils/logger.py +37 -0
- selenium_agent/utils/paths.py +57 -0
- selenium_agent/utils/spec_writer.py +126 -0
- selenium_agent/utils/url_extractor.py +52 -0
- selenium_python_ai_agent-0.2.0.dist-info/METADATA +412 -0
- selenium_python_ai_agent-0.2.0.dist-info/RECORD +35 -0
- selenium_python_ai_agent-0.2.0.dist-info/WHEEL +5 -0
- selenium_python_ai_agent-0.2.0.dist-info/entry_points.txt +2 -0
- selenium_python_ai_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- selenium_python_ai_agent-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SELENIUM ERROR MAP
|
|
3
|
+
==================
|
|
4
|
+
Maps known Selenium exceptions to root causes and exact fixes.
|
|
5
|
+
|
|
6
|
+
The Healer Agent uses this to:
|
|
7
|
+
- Identify error type from pytest output
|
|
8
|
+
- Apply targeted fix before asking the LLM
|
|
9
|
+
- Reduce LLM calls for common/known errors
|
|
10
|
+
|
|
11
|
+
This is what makes the Healer truly Selenium-specific,
|
|
12
|
+
not just a generic "ask LLM to fix" agent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ErrorFix:
|
|
21
|
+
"""A known Selenium error with its cause and fix."""
|
|
22
|
+
exception: str # Exception class name
|
|
23
|
+
pattern: str # String to search for in error output
|
|
24
|
+
cause: str # What causes this error
|
|
25
|
+
fix: str # How to fix it in code
|
|
26
|
+
code_before: str # Example of broken code
|
|
27
|
+
code_after: str # Example of fixed code
|
|
28
|
+
severity: str # low | medium | high
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SeleniumErrorMap:
|
|
32
|
+
"""
|
|
33
|
+
Lookup table of known Selenium errors → targeted fixes.
|
|
34
|
+
|
|
35
|
+
Used by the Healer Agent to apply precise fixes
|
|
36
|
+
before falling back to LLM healing.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
KNOWN_ERRORS: list[ErrorFix] = [
|
|
40
|
+
|
|
41
|
+
ErrorFix(
|
|
42
|
+
exception="NoSuchElementException",
|
|
43
|
+
pattern="no such element",
|
|
44
|
+
cause="Element not found in DOM — wrong locator or page not loaded",
|
|
45
|
+
fix="Add WebDriverWait for element presence, or fix the locator",
|
|
46
|
+
code_before='driver.find_element(By.ID, "username")',
|
|
47
|
+
code_after='WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.ID, "username")))',
|
|
48
|
+
severity="high",
|
|
49
|
+
),
|
|
50
|
+
|
|
51
|
+
ErrorFix(
|
|
52
|
+
exception="TimeoutException (wait_for_url)",
|
|
53
|
+
pattern="wait_for_url",
|
|
54
|
+
cause="The URL never changed after the action — many SPAs show the result "
|
|
55
|
+
"on the SAME page (status badge, toast, message) without navigating",
|
|
56
|
+
fix="Do NOT increase the timeout. Remove wait_for_url and instead assert an "
|
|
57
|
+
"in-page success indicator from the DOM scan (a status element whose text "
|
|
58
|
+
"changes, a success message, etc.)",
|
|
59
|
+
code_before="page.click(page.LOGIN_BUTTON)\npage.wait_for_url('dashboard')",
|
|
60
|
+
code_after="page.click(page.LOGIN_BUTTON)\n"
|
|
61
|
+
"# SPA: success shows on the same page — assert the status element\n"
|
|
62
|
+
"page.wait_for_text(page.LOGIN_STATUS, 'AUTHENTICATED')\n"
|
|
63
|
+
"assert 'AUTHENTICATED' in page.get_text(page.LOGIN_STATUS)",
|
|
64
|
+
severity="high",
|
|
65
|
+
),
|
|
66
|
+
|
|
67
|
+
ErrorFix(
|
|
68
|
+
exception="TimeoutException",
|
|
69
|
+
pattern="timeout",
|
|
70
|
+
cause="Element did not appear within the wait timeout",
|
|
71
|
+
fix="Increase timeout, verify locator is correct, or check if page loaded",
|
|
72
|
+
code_before='WebDriverWait(driver, 5).until(EC.visibility_of_element_located((By.ID, "result")))',
|
|
73
|
+
code_after='WebDriverWait(driver, 15).until(EC.visibility_of_element_located((By.ID, "result")))',
|
|
74
|
+
severity="medium",
|
|
75
|
+
),
|
|
76
|
+
|
|
77
|
+
ErrorFix(
|
|
78
|
+
exception="StaleElementReferenceException",
|
|
79
|
+
pattern="stale element reference",
|
|
80
|
+
cause="Element was found, then DOM was refreshed/updated — reference is now stale",
|
|
81
|
+
fix="Re-fetch the element inside the action, or use a retry wrapper",
|
|
82
|
+
code_before="""element = driver.find_element(By.ID, "btn")
|
|
83
|
+
page.load_more()
|
|
84
|
+
element.click() # stale!""",
|
|
85
|
+
code_after="""page.load_more()
|
|
86
|
+
# Re-fetch after DOM change
|
|
87
|
+
WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.ID, "btn"))).click()""",
|
|
88
|
+
severity="high",
|
|
89
|
+
),
|
|
90
|
+
|
|
91
|
+
ErrorFix(
|
|
92
|
+
exception="ElementNotInteractableException",
|
|
93
|
+
pattern="element not interactable",
|
|
94
|
+
cause="Element exists but is hidden, disabled, or off-screen",
|
|
95
|
+
fix="Scroll element into view, wait for it to be visible, or use JS click",
|
|
96
|
+
code_before='driver.find_element(By.ID, "submit").click()',
|
|
97
|
+
code_after="""element = WebDriverWait(driver, 10).until(
|
|
98
|
+
EC.element_to_be_clickable((By.ID, "submit"))
|
|
99
|
+
)
|
|
100
|
+
driver.execute_script("arguments[0].scrollIntoView(true);", element)
|
|
101
|
+
element.click()""",
|
|
102
|
+
severity="medium",
|
|
103
|
+
),
|
|
104
|
+
|
|
105
|
+
ErrorFix(
|
|
106
|
+
exception="ElementClickInterceptedException",
|
|
107
|
+
pattern="element click intercepted",
|
|
108
|
+
cause="Another element (overlay, modal, cookie banner) is blocking the click",
|
|
109
|
+
fix="Dismiss the overlay first, or use JS click as fallback",
|
|
110
|
+
code_before='driver.find_element(By.ID, "checkout").click()',
|
|
111
|
+
code_after="""# Option 1: Dismiss overlay if present
|
|
112
|
+
try:
|
|
113
|
+
WebDriverWait(driver, 3).until(
|
|
114
|
+
EC.element_to_be_clickable((By.CSS_SELECTOR, ".cookie-accept"))
|
|
115
|
+
).click()
|
|
116
|
+
except TimeoutException:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
# Option 2: JS click fallback
|
|
120
|
+
element = WebDriverWait(driver, 10).until(
|
|
121
|
+
EC.presence_of_element_located((By.ID, "checkout"))
|
|
122
|
+
)
|
|
123
|
+
driver.execute_script("arguments[0].click();", element)""",
|
|
124
|
+
severity="medium",
|
|
125
|
+
),
|
|
126
|
+
|
|
127
|
+
ErrorFix(
|
|
128
|
+
exception="MoveTargetOutOfBoundsException",
|
|
129
|
+
pattern="move target out of bounds",
|
|
130
|
+
cause="Element is outside the current viewport",
|
|
131
|
+
fix="Scroll element into view before hover/click",
|
|
132
|
+
code_before='ActionChains(driver).move_to_element(element).perform()',
|
|
133
|
+
code_after="""driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element)
|
|
134
|
+
ActionChains(driver).move_to_element(element).perform()""",
|
|
135
|
+
severity="low",
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
ErrorFix(
|
|
139
|
+
exception="WebDriverException (session)",
|
|
140
|
+
pattern="invalid session id",
|
|
141
|
+
cause="WebDriver session was closed or crashed",
|
|
142
|
+
fix="Ensure driver.quit() is in teardown, add session check in fixture",
|
|
143
|
+
code_before="""# Missing teardown
|
|
144
|
+
def test_login(driver):
|
|
145
|
+
driver.get("https://example.com")""",
|
|
146
|
+
code_after="""@pytest.fixture
|
|
147
|
+
def driver():
|
|
148
|
+
d = DriverFactory.create(browser="chrome", headless=True)
|
|
149
|
+
yield d
|
|
150
|
+
d.quit() # Always called, even on failure""",
|
|
151
|
+
severity="high",
|
|
152
|
+
),
|
|
153
|
+
|
|
154
|
+
ErrorFix(
|
|
155
|
+
exception="ImplicitWaitConflict",
|
|
156
|
+
pattern="implicit wait",
|
|
157
|
+
cause="Mixing implicit and explicit waits causes unpredictable timeouts",
|
|
158
|
+
fix="Remove implicit waits — use only WebDriverWait (explicit) everywhere",
|
|
159
|
+
code_before='driver.implicitly_wait(10) # conflicts with WebDriverWait',
|
|
160
|
+
code_after="""# Remove implicit wait
|
|
161
|
+
# Use explicit wait everywhere:
|
|
162
|
+
WebDriverWait(driver, 10).until(EC.visibility_of_element_located(locator))""",
|
|
163
|
+
severity="medium",
|
|
164
|
+
),
|
|
165
|
+
|
|
166
|
+
ErrorFix(
|
|
167
|
+
exception="NoAlertPresentException",
|
|
168
|
+
pattern="no alert",
|
|
169
|
+
cause="Trying to switch to alert before it appears",
|
|
170
|
+
fix="Wait for alert to be present using EC.alert_is_present()",
|
|
171
|
+
code_before='alert = driver.switch_to.alert',
|
|
172
|
+
code_after="""WebDriverWait(driver, 5).until(EC.alert_is_present())
|
|
173
|
+
alert = driver.switch_to.alert""",
|
|
174
|
+
severity="low",
|
|
175
|
+
),
|
|
176
|
+
|
|
177
|
+
ErrorFix(
|
|
178
|
+
exception="UnexpectedAlertPresentException",
|
|
179
|
+
pattern="unexpected alert",
|
|
180
|
+
cause="An unexpected JS alert appeared and blocked the driver",
|
|
181
|
+
fix="Handle alert in teardown or dismiss it before continuing",
|
|
182
|
+
code_before="# No alert handling",
|
|
183
|
+
code_after="""try:
|
|
184
|
+
WebDriverWait(driver, 2).until(EC.alert_is_present())
|
|
185
|
+
driver.switch_to.alert.dismiss()
|
|
186
|
+
except TimeoutException:
|
|
187
|
+
pass""",
|
|
188
|
+
severity="medium",
|
|
189
|
+
),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def find_fix(cls, error_output: str) -> ErrorFix | None:
|
|
194
|
+
"""
|
|
195
|
+
Search error output for known patterns and return fix.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
error_output: Full pytest error/traceback string
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
ErrorFix if a known error is found, None otherwise
|
|
202
|
+
"""
|
|
203
|
+
lower_output = error_output.lower()
|
|
204
|
+
for error in cls.KNOWN_ERRORS:
|
|
205
|
+
if error.pattern.lower() in lower_output:
|
|
206
|
+
return error
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def find_all_fixes(cls, error_output: str) -> list[ErrorFix]:
|
|
211
|
+
"""Find ALL matching errors in the output."""
|
|
212
|
+
lower_output = error_output.lower()
|
|
213
|
+
return [e for e in cls.KNOWN_ERRORS if e.pattern.lower() in lower_output]
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def get_fix_summary(cls, error_output: str) -> str:
|
|
217
|
+
"""Return a human-readable fix summary for the Healer Agent prompt."""
|
|
218
|
+
fixes = cls.find_all_fixes(error_output)
|
|
219
|
+
if not fixes:
|
|
220
|
+
return "No known Selenium error pattern matched. LLM analysis required."
|
|
221
|
+
|
|
222
|
+
lines = ["KNOWN SELENIUM ERRORS DETECTED:\n"]
|
|
223
|
+
for fix in fixes:
|
|
224
|
+
lines.append(f"❌ {fix.exception}")
|
|
225
|
+
lines.append(f" Cause: {fix.cause}")
|
|
226
|
+
lines.append(f" Fix: {fix.fix}")
|
|
227
|
+
lines.append(f" Before:\n{fix.code_before}")
|
|
228
|
+
lines.append(f" After:\n{fix.code_after}\n")
|
|
229
|
+
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def list_all(cls) -> list[str]:
|
|
234
|
+
"""Return list of all known exception names."""
|
|
235
|
+
return [e.exception for e in cls.KNOWN_ERRORS]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LOCATOR ADVISOR
|
|
3
|
+
===============
|
|
4
|
+
Selenium-specific intelligence for choosing the best locator strategy.
|
|
5
|
+
|
|
6
|
+
The Planner and Coder agents use this to:
|
|
7
|
+
- Rank locator strategies by reliability
|
|
8
|
+
- Detect anti-patterns in XPath/CSS
|
|
9
|
+
- Suggest better alternatives
|
|
10
|
+
- Embed locator guidance in generated code
|
|
11
|
+
|
|
12
|
+
Locator Priority (most → least reliable):
|
|
13
|
+
1. By.ID — unique, fast, stable
|
|
14
|
+
2. By.NAME — good for form fields
|
|
15
|
+
3. By.CSS_SELECTOR — flexible, fast, readable
|
|
16
|
+
4. By.XPATH — powerful but fragile if absolute
|
|
17
|
+
5. By.LINK_TEXT — brittle, language-dependent
|
|
18
|
+
6. By.CLASS_NAME — often not unique
|
|
19
|
+
7. By.TAG_NAME — almost never unique
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LocatorScore(IntEnum):
|
|
28
|
+
"""Reliability score for locator strategies (higher = better)."""
|
|
29
|
+
ID = 100
|
|
30
|
+
NAME = 90
|
|
31
|
+
CSS_SELECTOR = 80
|
|
32
|
+
XPATH_RELATIVE = 70
|
|
33
|
+
DATA_TESTID = 95 # data-testid is best practice
|
|
34
|
+
ARIA_LABEL = 75
|
|
35
|
+
LINK_TEXT = 50
|
|
36
|
+
PARTIAL_LINK_TEXT = 45
|
|
37
|
+
CLASS_NAME = 40
|
|
38
|
+
TAG_NAME = 10
|
|
39
|
+
XPATH_ABSOLUTE = 5 # Absolute XPath — worst
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LocatorSuggestion:
|
|
44
|
+
"""A single locator suggestion with explanation."""
|
|
45
|
+
strategy: str # By.ID, By.CSS_SELECTOR, etc.
|
|
46
|
+
value: str # The locator value
|
|
47
|
+
score: int # Reliability score
|
|
48
|
+
reason: str # Why this was suggested
|
|
49
|
+
warning: str = "" # Any anti-pattern warning
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LocatorAdvisor:
|
|
53
|
+
"""
|
|
54
|
+
Advises on the best Selenium locator strategy.
|
|
55
|
+
|
|
56
|
+
Used by Planner Agent to embed locator guidance in test plans,
|
|
57
|
+
and by Coder Agent to generate reliable locators.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Anti-patterns to warn about
|
|
61
|
+
ANTI_PATTERNS = {
|
|
62
|
+
"absolute_xpath": {
|
|
63
|
+
"pattern": r"^/html/body",
|
|
64
|
+
"warning": "Absolute XPath is extremely fragile — breaks on any DOM change.",
|
|
65
|
+
"suggestion": "Use By.ID or By.CSS_SELECTOR instead."
|
|
66
|
+
},
|
|
67
|
+
"index_xpath": {
|
|
68
|
+
"pattern": r"\[\d+\]",
|
|
69
|
+
"warning": "Index-based XPath (e.g. [2]) breaks when new elements are added.",
|
|
70
|
+
"suggestion": "Use a unique attribute like id, name, or data-testid."
|
|
71
|
+
},
|
|
72
|
+
"dynamic_class": {
|
|
73
|
+
"pattern": r"[a-z0-9]{8,}", # Random-looking class names
|
|
74
|
+
"warning": "This class name looks auto-generated and may change on rebuild.",
|
|
75
|
+
"suggestion": "Ask developers to add stable data-testid attributes."
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Locator strategy priority list (for Coder Agent prompt injection)
|
|
80
|
+
PRIORITY_GUIDE = """
|
|
81
|
+
SELENIUM LOCATOR STRATEGY — Priority Order:
|
|
82
|
+
|
|
83
|
+
1. data-testid attribute (BEST)
|
|
84
|
+
css = "[data-testid='login-button']"
|
|
85
|
+
→ Stable, developer-intended for testing
|
|
86
|
+
|
|
87
|
+
2. By.ID (EXCELLENT)
|
|
88
|
+
By.ID, "username"
|
|
89
|
+
→ Fast, unique per page spec
|
|
90
|
+
|
|
91
|
+
3. By.NAME (GOOD for forms)
|
|
92
|
+
By.NAME, "email"
|
|
93
|
+
→ Stable for form inputs
|
|
94
|
+
|
|
95
|
+
4. By.CSS_SELECTOR (GOOD)
|
|
96
|
+
By.CSS_SELECTOR, "button.login-btn"
|
|
97
|
+
By.CSS_SELECTOR, "input[placeholder='Email']"
|
|
98
|
+
By.CSS_SELECTOR, "form > button[type='submit']"
|
|
99
|
+
→ Readable, faster than XPath
|
|
100
|
+
|
|
101
|
+
5. Relative By.XPATH (ACCEPTABLE)
|
|
102
|
+
By.XPATH, "//button[contains(text(),'Login')]"
|
|
103
|
+
By.XPATH, "//input[@placeholder='Email']"
|
|
104
|
+
→ Use only when CSS won't work
|
|
105
|
+
|
|
106
|
+
6. By.LINK_TEXT (LIMITED)
|
|
107
|
+
By.LINK_TEXT, "Forgot Password"
|
|
108
|
+
→ Only for anchor tags, breaks on text change
|
|
109
|
+
|
|
110
|
+
NEVER USE:
|
|
111
|
+
❌ Absolute XPath: /html/body/div[1]/form/input[2]
|
|
112
|
+
❌ Index-based: (//button)[3]
|
|
113
|
+
❌ Auto-generated classes: .sc-bdXHXe.hQDDGZ
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def get_priority_guide(cls) -> str:
|
|
118
|
+
"""Return the full locator priority guide for injection into LLM prompts."""
|
|
119
|
+
return cls.PRIORITY_GUIDE
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def rank_locators(cls, locators: list[dict]) -> list[LocatorSuggestion]:
|
|
123
|
+
"""
|
|
124
|
+
Rank a list of locator options by reliability.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
locators: List of dicts with 'strategy' and 'value' keys
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Sorted list of LocatorSuggestion (best first)
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
ranked = LocatorAdvisor.rank_locators([
|
|
134
|
+
{"strategy": "xpath", "value": "/html/body/div/input"},
|
|
135
|
+
{"strategy": "id", "value": "username"},
|
|
136
|
+
{"strategy": "css", "value": "input.login-field"},
|
|
137
|
+
])
|
|
138
|
+
"""
|
|
139
|
+
strategy_scores = {
|
|
140
|
+
"id": LocatorScore.ID,
|
|
141
|
+
"name": LocatorScore.NAME,
|
|
142
|
+
"css": LocatorScore.CSS_SELECTOR,
|
|
143
|
+
"css_selector": LocatorScore.CSS_SELECTOR,
|
|
144
|
+
"xpath": LocatorScore.XPATH_RELATIVE,
|
|
145
|
+
"link_text": LocatorScore.LINK_TEXT,
|
|
146
|
+
"class_name": LocatorScore.CLASS_NAME,
|
|
147
|
+
"tag_name": LocatorScore.TAG_NAME,
|
|
148
|
+
"data_testid": LocatorScore.DATA_TESTID,
|
|
149
|
+
"aria_label": LocatorScore.ARIA_LABEL,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
suggestions = []
|
|
153
|
+
for loc in locators:
|
|
154
|
+
strategy = loc.get("strategy", "").lower().replace(" ", "_")
|
|
155
|
+
value = loc.get("value", "")
|
|
156
|
+
score = strategy_scores.get(strategy, 30)
|
|
157
|
+
|
|
158
|
+
# Penalize absolute XPath
|
|
159
|
+
if strategy == "xpath" and value.startswith("/html"):
|
|
160
|
+
score = LocatorScore.XPATH_ABSOLUTE
|
|
161
|
+
warning = "⚠️ Absolute XPath — extremely fragile!"
|
|
162
|
+
elif strategy == "xpath" and "[" in value and value[value.index("[") + 1].isdigit():
|
|
163
|
+
score = max(score - 30, 10)
|
|
164
|
+
warning = "⚠️ Index-based XPath — fragile!"
|
|
165
|
+
elif "[data-testid" in value:
|
|
166
|
+
score = LocatorScore.DATA_TESTID
|
|
167
|
+
warning = ""
|
|
168
|
+
else:
|
|
169
|
+
warning = ""
|
|
170
|
+
|
|
171
|
+
reason = cls._get_reason(strategy, score)
|
|
172
|
+
suggestions.append(LocatorSuggestion(
|
|
173
|
+
strategy=strategy,
|
|
174
|
+
value=value,
|
|
175
|
+
score=score,
|
|
176
|
+
reason=reason,
|
|
177
|
+
warning=warning,
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
return sorted(suggestions, key=lambda s: s.score, reverse=True)
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def best_locator(cls, locators: list[dict]) -> LocatorSuggestion:
|
|
184
|
+
"""Return the single best locator from a list."""
|
|
185
|
+
ranked = cls.rank_locators(locators)
|
|
186
|
+
return ranked[0] if ranked else None
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def _get_reason(cls, strategy: str, score: int) -> str:
|
|
190
|
+
reasons = {
|
|
191
|
+
"id": "Unique per-page, fastest lookup, most stable",
|
|
192
|
+
"name": "Good for form fields, stable in most frameworks",
|
|
193
|
+
"css": "Readable, fast, flexible — preferred over XPath",
|
|
194
|
+
"css_selector": "Readable, fast, flexible — preferred over XPath",
|
|
195
|
+
"xpath": "Use only when CSS/ID not available",
|
|
196
|
+
"link_text": "Brittle — breaks on text change or i18n",
|
|
197
|
+
"class_name": "Often not unique — combine with other strategies",
|
|
198
|
+
"tag_name": "Almost never unique — avoid",
|
|
199
|
+
"data_testid": "Best practice — developer-defined test hook",
|
|
200
|
+
"aria_label": "Good for accessibility-first apps",
|
|
201
|
+
}
|
|
202
|
+
return reasons.get(strategy, "No specific guidance available")
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def validate(cls, strategy: str, value: str) -> dict:
|
|
206
|
+
"""
|
|
207
|
+
Validate a single locator and return warnings.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
dict with 'valid', 'score', 'warnings', 'suggestions'
|
|
211
|
+
"""
|
|
212
|
+
import re
|
|
213
|
+
warnings = []
|
|
214
|
+
suggestions = []
|
|
215
|
+
|
|
216
|
+
if strategy.lower() == "xpath":
|
|
217
|
+
if value.startswith("/html"):
|
|
218
|
+
warnings.append("Absolute XPath is extremely fragile")
|
|
219
|
+
suggestions.append("Convert to By.ID or By.CSS_SELECTOR")
|
|
220
|
+
|
|
221
|
+
if re.search(r"\[\d+\]", value):
|
|
222
|
+
warnings.append("Index-based XPath breaks when DOM changes")
|
|
223
|
+
suggestions.append("Use unique attribute selectors instead")
|
|
224
|
+
|
|
225
|
+
if strategy.lower() in ("class_name", "css_selector"):
|
|
226
|
+
if re.search(r"\b[a-z0-9]{8,}\b", value):
|
|
227
|
+
warnings.append("Possible auto-generated class name detected")
|
|
228
|
+
suggestions.append("Request data-testid from developers")
|
|
229
|
+
|
|
230
|
+
score_map = {
|
|
231
|
+
"id": LocatorScore.ID,
|
|
232
|
+
"name": LocatorScore.NAME,
|
|
233
|
+
"css_selector": LocatorScore.CSS_SELECTOR,
|
|
234
|
+
"xpath": LocatorScore.XPATH_RELATIVE if not value.startswith("/html") else LocatorScore.XPATH_ABSOLUTE,
|
|
235
|
+
"link_text": LocatorScore.LINK_TEXT,
|
|
236
|
+
"class_name": LocatorScore.CLASS_NAME,
|
|
237
|
+
"tag_name": LocatorScore.TAG_NAME,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
score = score_map.get(strategy.lower(), 30)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"valid": len(warnings) == 0,
|
|
244
|
+
"score": score,
|
|
245
|
+
"warnings": warnings,
|
|
246
|
+
"suggestions": suggestions,
|
|
247
|
+
}
|