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,638 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HEALER AGENT
|
|
3
|
+
============
|
|
4
|
+
Works like Playwright's healer agent, for Selenium Python:
|
|
5
|
+
|
|
6
|
+
run tests → on failure: classify error (SeleniumErrorMap) →
|
|
7
|
+
re-scan the LIVE DOM of every URL the tests touch →
|
|
8
|
+
ask the LLM for a fix → validate the fix (syntax + architecture) →
|
|
9
|
+
write → re-run → repeat until green or retries exhausted.
|
|
10
|
+
|
|
11
|
+
Guarantees:
|
|
12
|
+
- The LAST fix is always verified with a final test run (never
|
|
13
|
+
"fixed and hoped").
|
|
14
|
+
- A syntactically broken LLM fix is NEVER written over a working file.
|
|
15
|
+
- Locators are only ever added to page objects; By imports are stripped
|
|
16
|
+
from test files automatically.
|
|
17
|
+
- With --test, all other test functions are preserved verbatim.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from selenium_agent.utils.logger import setup_logger
|
|
26
|
+
from selenium_agent.utils.llm import create_llm_client, DEFAULT_PROVIDER, get_default_model
|
|
27
|
+
from selenium_agent.utils.json_utils import extract_json_object, LLMJSONError
|
|
28
|
+
from selenium_agent.utils.code_validator import validate_python
|
|
29
|
+
from selenium_agent.selenium.error_map import SeleniumErrorMap
|
|
30
|
+
from selenium_agent.selenium.locator_scanner import scan_page_locators, format_for_llm
|
|
31
|
+
|
|
32
|
+
logger = setup_logger("HealerAgent")
|
|
33
|
+
|
|
34
|
+
PYTEST_TIMEOUT_SECONDS = 900
|
|
35
|
+
MAX_PYTEST_OUTPUT_CHARS = 12000
|
|
36
|
+
MAX_SCAN_URLS = 3
|
|
37
|
+
|
|
38
|
+
HEALER_SYSTEM_PROMPT = """
|
|
39
|
+
You are an expert Selenium Python debugger. Your job is 360° healing.
|
|
40
|
+
|
|
41
|
+
You receive:
|
|
42
|
+
1. The failing pytest output. It may contain two high-signal lines:
|
|
43
|
+
FAILURE_URL: <url> ← the page the browser was ON when it failed
|
|
44
|
+
FAILURE_ERRORS: [...] ← alert/validation messages visible at failure
|
|
45
|
+
(THE most direct clue — read this first)
|
|
46
|
+
FAILURE_PAGE_TEXT: <text> ← that page's visible text
|
|
47
|
+
2. Real DOM locators scanned from the live page(s)
|
|
48
|
+
3. Current code files (page objects + test files)
|
|
49
|
+
|
|
50
|
+
══ FORM SUBMITS THAT GO NOWHERE ══
|
|
51
|
+
If FAILURE_URL shows the app stayed on the same form page after submit,
|
|
52
|
+
the submission was REJECTED — read FAILURE_PAGE_TEXT for validation errors.
|
|
53
|
+
The usual cause: required fields were not filled. Fill EVERY input/select
|
|
54
|
+
listed in that page's scan block, not just the ones the test already types.
|
|
55
|
+
|
|
56
|
+
══ ARCHITECTURE RULE — READ THIS FIRST ══
|
|
57
|
+
|
|
58
|
+
Locators live ONLY in the Page Object class. NEVER in test files.
|
|
59
|
+
|
|
60
|
+
CORRECT pattern:
|
|
61
|
+
|
|
62
|
+
# pages/login_page.py ← locators go HERE
|
|
63
|
+
class LoginPage(BasePage):
|
|
64
|
+
URL = 'https://www.saucedemo.com'
|
|
65
|
+
USERNAME_INPUT = (By.CSS_SELECTOR, '#user-name')
|
|
66
|
+
PASSWORD_INPUT = (By.CSS_SELECTOR, '#password')
|
|
67
|
+
LOGIN_BUTTON = (By.CSS_SELECTOR, '[data-test="login-button"]')
|
|
68
|
+
ERROR_MESSAGE = (By.CSS_SELECTOR, '[data-test="error-button"]')
|
|
69
|
+
|
|
70
|
+
# tests/test_login.py ← use class attribute names, no By here
|
|
71
|
+
def test_login(driver):
|
|
72
|
+
page = LoginPage(driver)
|
|
73
|
+
page.open(LoginPage.URL)
|
|
74
|
+
page.fluent_wait(page.USERNAME_INPUT, 'visible')
|
|
75
|
+
page.type(page.USERNAME_INPUT, 'standard_user')
|
|
76
|
+
page.click(page.LOGIN_BUTTON)
|
|
77
|
+
assert page.is_visible(page.ERROR_MESSAGE)
|
|
78
|
+
|
|
79
|
+
WRONG — NEVER DO THIS:
|
|
80
|
+
# test file using By directly → FORBIDDEN
|
|
81
|
+
page.fluent_wait((By.CSS_SELECTOR, '[data-test="login-button"]'), 'clickable') ✗
|
|
82
|
+
page.click((By.CSS_SELECTOR, '#user-name')) ✗
|
|
83
|
+
|
|
84
|
+
# test file importing By → FORBIDDEN
|
|
85
|
+
from selenium.webdriver.common.by import By ✗ (only in page objects)
|
|
86
|
+
|
|
87
|
+
══ HOW TO FIX EACH ERROR TYPE ══
|
|
88
|
+
|
|
89
|
+
AttributeError: 'LoginPage' has no attribute 'X'
|
|
90
|
+
→ Add missing locator to the PAGE OBJECT class:
|
|
91
|
+
X = (By.CSS_SELECTOR, '<selector from DOM scan>')
|
|
92
|
+
→ Test file already calls page.X correctly — do NOT change test file for this
|
|
93
|
+
|
|
94
|
+
NoSuchElementException / TimeoutException
|
|
95
|
+
→ Wrong selector in page object. Fix the tuple value using DOM scan.
|
|
96
|
+
→ Change only the page object, not the test.
|
|
97
|
+
|
|
98
|
+
TimeoutException on wait_for_url(...)
|
|
99
|
+
→ The app probably does NOT navigate — SPAs show results on the SAME page.
|
|
100
|
+
→ Do NOT increase the timeout. Remove wait_for_url and assert an in-page
|
|
101
|
+
success indicator instead: a status element from the DOM scan whose text
|
|
102
|
+
changes (e.g. a status badge), or a success message element.
|
|
103
|
+
→ wait_for_text(locator, 'expected') is available on BasePage for this.
|
|
104
|
+
|
|
105
|
+
AttributeError: method not found
|
|
106
|
+
→ Fix method call in test to use correct BasePage methods:
|
|
107
|
+
self.fluent_wait(locator, 'visible'|'clickable'|'present'|'invisible')
|
|
108
|
+
self.find() self.click() self.type() self.get_text()
|
|
109
|
+
self.wait_for_url() self.is_visible() self.open() self.safe_type()
|
|
110
|
+
NEVER: find_element(), wait_for_element_visible(), time.sleep()
|
|
111
|
+
|
|
112
|
+
METHOD SIGNATURES — locator is ALWAYS a (By.X, 'selector') tuple:
|
|
113
|
+
wait_for_text(LOCATOR_TUPLE, 'expected text') ✓
|
|
114
|
+
wait_for_text('h3', 'expected text') ✗ raw string is not a locator
|
|
115
|
+
wait_for_url('url-fragment') ← the ONLY string-taking wait
|
|
116
|
+
|
|
117
|
+
DRIVER / FIXTURES:
|
|
118
|
+
conftest.py provides the `driver` fixture — test functions accept `driver`
|
|
119
|
+
as a parameter. NEVER define a driver fixture in a test file, NEVER
|
|
120
|
+
import/call DriverFactory in test files, NEVER create drivers manually.
|
|
121
|
+
"fixture 'driver' not found" → the test file broke this rule; restore the
|
|
122
|
+
plain `def test_x(driver):` signature.
|
|
123
|
+
|
|
124
|
+
AssertionError
|
|
125
|
+
→ Read the assertion and the actual value in the output. Decide whether the
|
|
126
|
+
expectation is wrong (fix assertion) or the app state was not reached
|
|
127
|
+
(fix waits / navigation before the assertion).
|
|
128
|
+
→ DOM-scan text is the page's PRE-ACTION state. Never assert pre-action
|
|
129
|
+
text (e.g. "STANDBY") as the post-action outcome — assert the CHANGED
|
|
130
|
+
state the scenario expects (e.g. "AUTHENTICATED", a success message).
|
|
131
|
+
→ When reading a "LABEL: value" element to use its value, strip the label:
|
|
132
|
+
value = page.get_text(LOCATOR).split(':', 1)[-1].strip()
|
|
133
|
+
|
|
134
|
+
ImportError
|
|
135
|
+
→ Fix import path. Never add sys.path.
|
|
136
|
+
|
|
137
|
+
StaleElementReferenceException
|
|
138
|
+
→ Re-fetch using fluent_wait in page object method.
|
|
139
|
+
|
|
140
|
+
══ LOCATOR RULES ══
|
|
141
|
+
- CSS preferred over XPath
|
|
142
|
+
- Use ONLY selectors from the DOM scan — never guess
|
|
143
|
+
- The scan is grouped per page (═══ PAGE: <url> ═══). When fixing a page
|
|
144
|
+
object, use ONLY locators from THAT page's block — NEVER borrow a
|
|
145
|
+
locator from a different page's block (it will not exist on this page)
|
|
146
|
+
- NEVER use a selector marked "NOT UNIQUE" for a single element
|
|
147
|
+
- Format in page object: NAME = (By.CSS_SELECTOR, 'selector')
|
|
148
|
+
- test files reference by name: page.NAME — no By, no raw strings
|
|
149
|
+
|
|
150
|
+
══ TEST DATA ══
|
|
151
|
+
- Errors like "already taken" / "already exists" / "already associated"
|
|
152
|
+
mean the test data is HARDCODED and collides with a previous run.
|
|
153
|
+
Fix: generate unique values at runtime in the test:
|
|
154
|
+
import uuid
|
|
155
|
+
unique = uuid.uuid4().hex[:8]
|
|
156
|
+
email = "qa." + unique + "@example.com"
|
|
157
|
+
- "password has appeared in a data leak" / "password too weak" means the
|
|
158
|
+
password is a common pattern (Password@123 etc.) — breach-list checks
|
|
159
|
+
reject it. Fix: password = "Xk9#" + unique + "!Qz" (strong AND unique).
|
|
160
|
+
|
|
161
|
+
══ CAPTCHA / BOT PROTECTION ══
|
|
162
|
+
If the DOM scan reports CAPTCHA/bot protection on a page, the flow is
|
|
163
|
+
blocked BY DESIGN — no locator or wait fix will make it pass, and captcha
|
|
164
|
+
must never be bypassed. Return {"fixed_files": [], "fix_summary":
|
|
165
|
+
"BLOCKED: captcha/bot protection on <url> — run against an environment
|
|
166
|
+
with captcha disabled"} instead of changing code.
|
|
167
|
+
|
|
168
|
+
══ FILE RULES ══
|
|
169
|
+
- Return COMPLETE files — never truncate, never drop functions
|
|
170
|
+
- Fix all issues in one pass
|
|
171
|
+
- If AttributeError on missing locator: fix page object file, preserve test file
|
|
172
|
+
|
|
173
|
+
REQUIRED IMPORTS in page objects only:
|
|
174
|
+
from selenium.webdriver.common.by import By
|
|
175
|
+
from selenium_agent.selenium.base_page import BasePage
|
|
176
|
+
|
|
177
|
+
REQUIRED IMPORTS in test files:
|
|
178
|
+
import pytest
|
|
179
|
+
from selenium_agent.selenium.driver_factory import DriverFactory
|
|
180
|
+
from pages.login_page import LoginPage
|
|
181
|
+
# NO 'from selenium.webdriver.common.by import By' in test files
|
|
182
|
+
|
|
183
|
+
Respond with valid JSON only:
|
|
184
|
+
{"fixed_files": [{"filename": "pages/login_page.py", "content": "..."}], "fix_summary": "..."}
|
|
185
|
+
|
|
186
|
+
filename must be relative: "pages/login_page.py" or "tests/test_login.py"
|
|
187
|
+
NEVER include the output_dir prefix.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
HEALER_SYSTEM_PROMPT_TARGETED = """
|
|
191
|
+
You are an expert Selenium Python debugger doing a SURGICAL fix.
|
|
192
|
+
|
|
193
|
+
══ ARCHITECTURE RULE — READ THIS FIRST ══
|
|
194
|
+
|
|
195
|
+
Locators live ONLY in the Page Object class. NEVER in test files.
|
|
196
|
+
|
|
197
|
+
# pages/login_page.py ← locators go HERE as class constants
|
|
198
|
+
class LoginPage(BasePage):
|
|
199
|
+
ERROR_MESSAGE = (By.CSS_SELECTOR, '[data-test="error-button"]')
|
|
200
|
+
|
|
201
|
+
# tests/test_login.py ← reference by name only, NO By import
|
|
202
|
+
assert page.is_visible(page.ERROR_MESSAGE)
|
|
203
|
+
|
|
204
|
+
NEVER add By or raw locator tuples in test files.
|
|
205
|
+
|
|
206
|
+
══ YOUR TASK ══
|
|
207
|
+
Fix ONE specific test function (indicated below).
|
|
208
|
+
1. If the fix requires a new/corrected locator → add/fix it in the PAGE OBJECT
|
|
209
|
+
2. Return COMPLETE content for every file you change
|
|
210
|
+
3. ALL other test functions must be preserved exactly as-is
|
|
211
|
+
4. Do NOT drop imports, fixtures, or class definitions
|
|
212
|
+
|
|
213
|
+
Common fixes:
|
|
214
|
+
AttributeError has no attribute X → add X to page object class, NOT test file
|
|
215
|
+
NoSuchElementException → fix locator in page object using DOM scan
|
|
216
|
+
TimeoutException → fix locator or increase timeout in page object
|
|
217
|
+
Wrong BasePage method → use fluent_wait / find / click / type / get_text
|
|
218
|
+
|
|
219
|
+
LOCATOR PREFERENCE: CSS over XPath. Use DOM scan results — never guess.
|
|
220
|
+
|
|
221
|
+
Respond with valid JSON only:
|
|
222
|
+
{"fixed_files": [{"filename": "pages/login_page.py", "content": "..."}], "fix_summary": "..."}
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class HealerAgent:
|
|
227
|
+
def __init__(self, api_key: str, output_dir: str = "generated_tests",
|
|
228
|
+
max_retries: int = 5, provider: str = DEFAULT_PROVIDER,
|
|
229
|
+
model: str | None = None):
|
|
230
|
+
resolved_model = model or get_default_model(provider)
|
|
231
|
+
self.client = create_llm_client(provider=provider, api_key=api_key, model=resolved_model)
|
|
232
|
+
self.output_dir = str(Path(output_dir).resolve())
|
|
233
|
+
self.max_retries = max_retries
|
|
234
|
+
|
|
235
|
+
# ── Path handling ──────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
def _resolve_paths(self, file_paths: list[str]) -> dict[str, Path]:
|
|
238
|
+
"""Map relative label → absolute Path. CWD-first to avoid double-path bug."""
|
|
239
|
+
resolved: dict[str, Path] = {}
|
|
240
|
+
output_root = Path(self.output_dir)
|
|
241
|
+
|
|
242
|
+
for fp in file_paths:
|
|
243
|
+
p = Path(fp)
|
|
244
|
+
|
|
245
|
+
if p.is_absolute():
|
|
246
|
+
absolute = p
|
|
247
|
+
else:
|
|
248
|
+
candidates = []
|
|
249
|
+
# Priority 1: path already carries the output_dir prefix
|
|
250
|
+
# ("generated_tests/pages/x.py") — strip it, don't double-join
|
|
251
|
+
if p.parts and p.parts[0] == output_root.name:
|
|
252
|
+
candidates.append((output_root.parent / p).resolve())
|
|
253
|
+
# Priority 2: resolve against CWD (user typed the path)
|
|
254
|
+
candidates.append((Path.cwd() / p).resolve())
|
|
255
|
+
# Priority 3: resolve against output_dir (internal call)
|
|
256
|
+
candidates.append((output_root / p).resolve())
|
|
257
|
+
|
|
258
|
+
absolute = next((c for c in candidates if c.exists()), candidates[-1])
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
label = str(absolute.relative_to(output_root))
|
|
262
|
+
except ValueError:
|
|
263
|
+
label = p.name
|
|
264
|
+
|
|
265
|
+
resolved[label] = absolute
|
|
266
|
+
|
|
267
|
+
return resolved
|
|
268
|
+
|
|
269
|
+
def _read_files(self, resolved: dict[str, Path]) -> dict[str, str]:
|
|
270
|
+
contents: dict[str, str] = {}
|
|
271
|
+
for label, absolute in resolved.items():
|
|
272
|
+
if absolute.exists():
|
|
273
|
+
contents[label] = absolute.read_text(encoding="utf-8")
|
|
274
|
+
else:
|
|
275
|
+
logger.warning(f"⚠️ Not found: {absolute}")
|
|
276
|
+
return contents
|
|
277
|
+
|
|
278
|
+
def _write_fixed_file(self, filename: str, content: str,
|
|
279
|
+
known_files: dict[str, Path]) -> Path:
|
|
280
|
+
output_root = Path(self.output_dir)
|
|
281
|
+
norm = Path(filename)
|
|
282
|
+
if norm.is_absolute():
|
|
283
|
+
destination = norm
|
|
284
|
+
else:
|
|
285
|
+
if filename in known_files:
|
|
286
|
+
destination = known_files[filename]
|
|
287
|
+
else:
|
|
288
|
+
parts = norm.parts
|
|
289
|
+
if parts and parts[0] == output_root.name:
|
|
290
|
+
norm = Path(*parts[1:])
|
|
291
|
+
destination = (output_root / norm).resolve()
|
|
292
|
+
|
|
293
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
destination.write_text(content, encoding="utf-8")
|
|
295
|
+
return destination
|
|
296
|
+
|
|
297
|
+
# ── Test execution ─────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
def _run_tests(self, test_files: list[str], test_filter: str | None = None) -> tuple[bool, str]:
|
|
300
|
+
cmd = [sys.executable, "-m", "pytest"] + test_files + ["-v", "--tb=short"]
|
|
301
|
+
|
|
302
|
+
if test_filter:
|
|
303
|
+
cmd += ["-k", test_filter]
|
|
304
|
+
logger.info(f"🎯 Filter: -k '{test_filter}'")
|
|
305
|
+
|
|
306
|
+
logger.info(f"🧪 Running: {' '.join(cmd)}")
|
|
307
|
+
try:
|
|
308
|
+
result = subprocess.run(
|
|
309
|
+
cmd, capture_output=True, text=True, timeout=PYTEST_TIMEOUT_SECONDS,
|
|
310
|
+
)
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
return False, (
|
|
313
|
+
f"pytest timed out after {PYTEST_TIMEOUT_SECONDS}s — "
|
|
314
|
+
f"likely a hung wait or a browser that never loaded."
|
|
315
|
+
)
|
|
316
|
+
output = result.stdout + result.stderr
|
|
317
|
+
return self._is_green(result.returncode == 0, output), output
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _is_green(returncode_ok: bool, output: str) -> bool:
|
|
321
|
+
"""
|
|
322
|
+
Green means tests genuinely PASSED — not merely exit code 0.
|
|
323
|
+
pytest exits 0 when every test is SKIPPED, but a generated suite
|
|
324
|
+
full of skip placeholders proves nothing.
|
|
325
|
+
"""
|
|
326
|
+
if not returncode_ok:
|
|
327
|
+
return False
|
|
328
|
+
if "Pending:" in output: # generated skip placeholders = unfinished work
|
|
329
|
+
return False
|
|
330
|
+
m = re.search(r"(\d+) passed", output)
|
|
331
|
+
return bool(m) and int(m.group(1)) >= 1
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def _trim_output(output: str) -> str:
|
|
335
|
+
"""Keep the tail of pytest output — that's where failures live."""
|
|
336
|
+
if len(output) <= MAX_PYTEST_OUTPUT_CHARS:
|
|
337
|
+
return output
|
|
338
|
+
return "...[output trimmed]...\n" + output[-MAX_PYTEST_OUTPUT_CHARS:]
|
|
339
|
+
|
|
340
|
+
# ── Source surgery helpers ─────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
def _extract_function(self, source: str, func_name: str) -> str | None:
|
|
343
|
+
"""Extract a single top-level function block from source, or None."""
|
|
344
|
+
lines = source.splitlines(keepends=True)
|
|
345
|
+
start = None
|
|
346
|
+
for i, line in enumerate(lines):
|
|
347
|
+
if re.match(rf'^def {re.escape(func_name)}\b', line):
|
|
348
|
+
start = i
|
|
349
|
+
break
|
|
350
|
+
if start is None:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
func_lines = [lines[start]]
|
|
354
|
+
for line in lines[start + 1:]:
|
|
355
|
+
if line and line[0] not in (' ', '\t', '\n', '\r', '#'):
|
|
356
|
+
break
|
|
357
|
+
func_lines.append(line)
|
|
358
|
+
return "".join(func_lines)
|
|
359
|
+
|
|
360
|
+
def _replace_function(self, source: str, func_name: str, new_func: str) -> str:
|
|
361
|
+
"""Replace one function in source; all other content preserved exactly."""
|
|
362
|
+
lines = source.splitlines(keepends=True)
|
|
363
|
+
start = None
|
|
364
|
+
for i, line in enumerate(lines):
|
|
365
|
+
if re.match(rf'^def {re.escape(func_name)}\b', line):
|
|
366
|
+
start = i
|
|
367
|
+
break
|
|
368
|
+
if start is None:
|
|
369
|
+
return source + "\n\n" + new_func
|
|
370
|
+
|
|
371
|
+
end = start + 1
|
|
372
|
+
while end < len(lines):
|
|
373
|
+
line = lines[end]
|
|
374
|
+
if line and line[0] not in (' ', '\t', '\n', '\r', '#'):
|
|
375
|
+
break
|
|
376
|
+
end += 1
|
|
377
|
+
|
|
378
|
+
new_lines = lines[:start] + [new_func.rstrip('\n') + '\n'] + lines[end:]
|
|
379
|
+
return "".join(new_lines)
|
|
380
|
+
|
|
381
|
+
def _sanitize_test_file(self, content: str) -> str:
|
|
382
|
+
"""Remove By imports the LLM incorrectly added to test files."""
|
|
383
|
+
lines = content.splitlines(keepends=True)
|
|
384
|
+
cleaned = []
|
|
385
|
+
for line in lines:
|
|
386
|
+
if re.search(r'from selenium\.webdriver\.common\.by import By', line) or \
|
|
387
|
+
re.search(r'from selenium.*import.*\bBy\b', line):
|
|
388
|
+
logger.warning("⚠️ Removed 'By' import from test file (belongs in page object)")
|
|
389
|
+
continue
|
|
390
|
+
cleaned.append(line)
|
|
391
|
+
return "".join(cleaned)
|
|
392
|
+
|
|
393
|
+
def _merge_preserve_others(self, original: str, fixed: str, target_func: str) -> str:
|
|
394
|
+
"""Restore any functions the LLM dropped during a targeted fix."""
|
|
395
|
+
original_funcs = re.findall(r'^def (\w+)\b', original, re.MULTILINE)
|
|
396
|
+
result = fixed
|
|
397
|
+
for func_name in original_funcs:
|
|
398
|
+
if func_name == target_func:
|
|
399
|
+
continue
|
|
400
|
+
if not re.search(rf'^def {re.escape(func_name)}\b', result, re.MULTILINE):
|
|
401
|
+
logger.warning(f"⚠️ LLM dropped '{func_name}' — restoring from original")
|
|
402
|
+
original_func_body = self._extract_function(original, func_name)
|
|
403
|
+
if original_func_body:
|
|
404
|
+
result = result.rstrip('\n') + '\n\n\n' + original_func_body
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
# ── Context discovery ──────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
def _auto_discover_related_files(self, resolved: dict[str, Path]) -> dict[str, Path]:
|
|
410
|
+
"""Given test files, auto-discover the page objects they import."""
|
|
411
|
+
output_root = Path(self.output_dir)
|
|
412
|
+
extra: dict[str, Path] = {}
|
|
413
|
+
|
|
414
|
+
for label, path in list(resolved.items()):
|
|
415
|
+
if not ("test_" in path.name and path.suffix == ".py" and path.exists()):
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
source = path.read_text(encoding="utf-8")
|
|
419
|
+
imports = re.findall(r'from\s+pages\.?(\w+)?\s+import\s+(\w+)', source)
|
|
420
|
+
for module, _ in imports:
|
|
421
|
+
if not module:
|
|
422
|
+
continue
|
|
423
|
+
pages_dir = path.parent.parent / "pages"
|
|
424
|
+
candidate = pages_dir / f"{module}.py"
|
|
425
|
+
if candidate.exists() and str(candidate) not in [str(v) for v in resolved.values()]:
|
|
426
|
+
try:
|
|
427
|
+
lbl = str(candidate.relative_to(output_root))
|
|
428
|
+
except ValueError:
|
|
429
|
+
lbl = candidate.name
|
|
430
|
+
extra[lbl] = candidate
|
|
431
|
+
logger.info(f"🔗 Auto-discovered page file: {candidate.name}")
|
|
432
|
+
|
|
433
|
+
return extra
|
|
434
|
+
|
|
435
|
+
def _extract_urls(self, resolved: dict[str, Path], pytest_output: str = "") -> list[str]:
|
|
436
|
+
"""
|
|
437
|
+
Collect the URLs the tests actually touch, best-signal first:
|
|
438
|
+
1. failure-time URLs from pytest output, but ONLY on domains the
|
|
439
|
+
code itself navigates to (filters out pytest/selenium doc links
|
|
440
|
+
that appear in warnings and tracebacks)
|
|
441
|
+
2. URL constants / open()/get() calls in the code files
|
|
442
|
+
"""
|
|
443
|
+
from urllib.parse import urlparse
|
|
444
|
+
|
|
445
|
+
# URLs the code navigates to — these define the app's domains
|
|
446
|
+
code_urls: list[str] = []
|
|
447
|
+
code_url_re = re.compile(
|
|
448
|
+
r'(?:URL\s*=\s*|self\.open\(|page\.open\(|driver\.get\()[\'"](https?://[^\'"]+)[\'"]',
|
|
449
|
+
re.IGNORECASE,
|
|
450
|
+
)
|
|
451
|
+
for label, path in resolved.items():
|
|
452
|
+
if path.exists() and path.suffix == ".py":
|
|
453
|
+
try:
|
|
454
|
+
for m in code_url_re.finditer(path.read_text(encoding="utf-8")):
|
|
455
|
+
url = m.group(1).rstrip("/")
|
|
456
|
+
if url not in code_urls:
|
|
457
|
+
code_urls.append(url)
|
|
458
|
+
except Exception:
|
|
459
|
+
pass
|
|
460
|
+
code_domains = {urlparse(u).netloc for u in code_urls}
|
|
461
|
+
|
|
462
|
+
# Failure-time URLs (assertion messages, current_url dumps) — the page
|
|
463
|
+
# the browser was ON when it failed is the best thing to re-scan.
|
|
464
|
+
noise = ("w3.org", "selenium.dev", "docs.pytest.org", "readthedocs",
|
|
465
|
+
"github.com", "python.org")
|
|
466
|
+
output_urls: list[str] = []
|
|
467
|
+
for m in re.finditer(r'https?://[^\s\'")\]]+', pytest_output):
|
|
468
|
+
url = m.group(0).rstrip('.,;')
|
|
469
|
+
if url not in output_urls and not any(d in url for d in noise):
|
|
470
|
+
output_urls.append(url)
|
|
471
|
+
|
|
472
|
+
urls: list[str] = []
|
|
473
|
+
for url in output_urls:
|
|
474
|
+
if urlparse(url).netloc in code_domains and url not in urls:
|
|
475
|
+
urls.append(url)
|
|
476
|
+
for url in code_urls:
|
|
477
|
+
if url not in urls:
|
|
478
|
+
urls.append(url)
|
|
479
|
+
if not urls: # no code URLs found — trust filtered output URLs
|
|
480
|
+
urls = output_urls
|
|
481
|
+
|
|
482
|
+
return urls[:MAX_SCAN_URLS]
|
|
483
|
+
|
|
484
|
+
# ── Main heal loop ─────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
def heal(self, saved_files: list[str], test_filter: str | None = None) -> dict:
|
|
487
|
+
resolved = self._resolve_paths(saved_files)
|
|
488
|
+
|
|
489
|
+
extra = self._auto_discover_related_files(resolved)
|
|
490
|
+
if extra:
|
|
491
|
+
resolved.update(extra)
|
|
492
|
+
logger.info(f"📎 Added {len(extra)} related file(s) to heal context")
|
|
493
|
+
|
|
494
|
+
test_absolutes = [
|
|
495
|
+
str(p) for label, p in resolved.items()
|
|
496
|
+
if "test_" in p.name and p.suffix == ".py"
|
|
497
|
+
]
|
|
498
|
+
|
|
499
|
+
if not test_absolutes:
|
|
500
|
+
logger.warning("⚠️ No test files found to run")
|
|
501
|
+
return {"status": "no_tests", "attempts": 0, "output": ""}
|
|
502
|
+
|
|
503
|
+
if test_filter:
|
|
504
|
+
logger.info(f"🎯 Heal scope: '{test_filter}' only — other tests preserved")
|
|
505
|
+
|
|
506
|
+
scan_cache: dict[str, str] = {}
|
|
507
|
+
output = ""
|
|
508
|
+
attempt = 0
|
|
509
|
+
|
|
510
|
+
# max_retries FIX attempts; every fix is verified by the run at the
|
|
511
|
+
# top of the next loop iteration or by the final verification run.
|
|
512
|
+
for attempt in range(1, self.max_retries + 1):
|
|
513
|
+
logger.info(f"🩺 Heal attempt {attempt}/{self.max_retries}")
|
|
514
|
+
passed, output = self._run_tests(test_absolutes, test_filter=test_filter)
|
|
515
|
+
|
|
516
|
+
if passed:
|
|
517
|
+
logger.info("✅ All tests passing!")
|
|
518
|
+
return {"status": "passed", "attempts": attempt, "output": output}
|
|
519
|
+
|
|
520
|
+
logger.warning(f"❌ Failed on attempt {attempt}")
|
|
521
|
+
try:
|
|
522
|
+
applied = self._fix_once(resolved, output, test_filter, scan_cache)
|
|
523
|
+
except LLMJSONError as exc:
|
|
524
|
+
logger.error(f"💥 Healer LLM returned unusable JSON: {exc}")
|
|
525
|
+
applied = False
|
|
526
|
+
if not applied:
|
|
527
|
+
logger.warning("⚠️ No usable fix produced this round")
|
|
528
|
+
|
|
529
|
+
# ── Final verification: the last fix must prove itself ──
|
|
530
|
+
logger.info("🔁 Final verification run")
|
|
531
|
+
passed, output = self._run_tests(test_absolutes, test_filter=test_filter)
|
|
532
|
+
if passed:
|
|
533
|
+
logger.info("✅ All tests passing after final fix!")
|
|
534
|
+
return {"status": "passed", "attempts": self.max_retries, "output": output}
|
|
535
|
+
|
|
536
|
+
logger.error(f"💀 Could not fix after {self.max_retries} attempts")
|
|
537
|
+
return {"status": "failed", "attempts": self.max_retries, "output": output}
|
|
538
|
+
|
|
539
|
+
def _fix_once(self, resolved: dict[str, Path], output: str,
|
|
540
|
+
test_filter: str | None, scan_cache: dict[str, str]) -> bool:
|
|
541
|
+
"""One LLM fix round. Returns True if at least one file was updated."""
|
|
542
|
+
known_fix = SeleniumErrorMap.get_fix_summary(output)
|
|
543
|
+
|
|
544
|
+
# ── DOM re-scan of every URL the tests touch (live ground truth) ──
|
|
545
|
+
# Pure-Python failures (import/collection/API-usage errors) don't
|
|
546
|
+
# need a browser — skip the scans and fix the code directly.
|
|
547
|
+
locator_context = ""
|
|
548
|
+
needs_dom = ("selenium.common.exceptions" in output
|
|
549
|
+
or "FAILURE_URL:" in output)
|
|
550
|
+
urls = self._extract_urls(resolved, pytest_output=output) if needs_dom else []
|
|
551
|
+
if not needs_dom:
|
|
552
|
+
logger.info("🐍 Pure Python failure — skipping DOM scans")
|
|
553
|
+
blocks = []
|
|
554
|
+
for url in urls:
|
|
555
|
+
if url not in scan_cache:
|
|
556
|
+
logger.info(f"🔍 DOM scan — real locators for healer: {url}")
|
|
557
|
+
elements = scan_page_locators(url, headless=True)
|
|
558
|
+
if any(el.get("kind") == "captcha" for el in elements):
|
|
559
|
+
logger.warning(
|
|
560
|
+
f"🚫 CAPTCHA / bot protection detected on {url} — "
|
|
561
|
+
f"flows behind it cannot (and must not) be automated. "
|
|
562
|
+
f"Run this flow on an environment with captcha disabled."
|
|
563
|
+
)
|
|
564
|
+
scan_cache[url] = format_for_llm(elements, context="healing")
|
|
565
|
+
if scan_cache[url]:
|
|
566
|
+
blocks.append(f"═══ PAGE: {url} ═══\n{scan_cache[url]}")
|
|
567
|
+
if blocks:
|
|
568
|
+
locator_context = "\n\n".join(blocks)
|
|
569
|
+
logger.info(f"✅ Real DOM locators injected from {len(blocks)} page(s)")
|
|
570
|
+
|
|
571
|
+
file_contents = self._read_files(resolved)
|
|
572
|
+
files_text = "\n\n".join(f"# File: {k}\n{v}" for k, v in file_contents.items())
|
|
573
|
+
|
|
574
|
+
if test_filter:
|
|
575
|
+
system_prompt = HEALER_SYSTEM_PROMPT_TARGETED
|
|
576
|
+
target_instruction = (
|
|
577
|
+
f"\n\n🎯 TARGETED FIX — Fix ONLY this test: '{test_filter}'\n"
|
|
578
|
+
f"ALL other test functions must be returned unchanged.\n"
|
|
579
|
+
f"Return the COMPLETE file — do NOT truncate or drop any tests.\n"
|
|
580
|
+
)
|
|
581
|
+
else:
|
|
582
|
+
system_prompt = HEALER_SYSTEM_PROMPT
|
|
583
|
+
target_instruction = ""
|
|
584
|
+
|
|
585
|
+
raw = self.client.generate_text(
|
|
586
|
+
system_prompt=system_prompt,
|
|
587
|
+
user_prompt=(
|
|
588
|
+
f"Fix these failing Selenium tests.\n\n"
|
|
589
|
+
f"SELENIUM ERROR ANALYSIS:\n{known_fix}\n\n"
|
|
590
|
+
f"{locator_context}\n"
|
|
591
|
+
f"PYTEST OUTPUT:\n{self._trim_output(output)}\n"
|
|
592
|
+
f"{target_instruction}\n"
|
|
593
|
+
f"CURRENT CODE:\n{files_text}"
|
|
594
|
+
),
|
|
595
|
+
max_tokens=8000,
|
|
596
|
+
json_mode=True,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
result = extract_json_object(raw)
|
|
600
|
+
logger.info(f"🔧 Fix: {result.get('fix_summary', 'No summary')}")
|
|
601
|
+
|
|
602
|
+
wrote_any = False
|
|
603
|
+
for file_info in result.get("fixed_files", []):
|
|
604
|
+
filename = file_info.get("filename", "")
|
|
605
|
+
fixed_content = file_info.get("content", "")
|
|
606
|
+
if not filename or not fixed_content:
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
# Test files must never contain By imports / raw locators
|
|
610
|
+
if Path(filename).name.startswith("test_"):
|
|
611
|
+
fixed_content = self._sanitize_test_file(fixed_content)
|
|
612
|
+
|
|
613
|
+
# Targeted mode: restore any functions the LLM dropped
|
|
614
|
+
if test_filter:
|
|
615
|
+
original_path = next(
|
|
616
|
+
(p for l, p in resolved.items()
|
|
617
|
+
if p.name == Path(filename).name),
|
|
618
|
+
None,
|
|
619
|
+
)
|
|
620
|
+
if original_path and original_path.exists():
|
|
621
|
+
original = original_path.read_text(encoding="utf-8")
|
|
622
|
+
fixed_content = self._merge_preserve_others(
|
|
623
|
+
original, fixed_content, test_filter
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Never overwrite a working file with a syntactically broken fix
|
|
627
|
+
validation = validate_python(filename, fixed_content)
|
|
628
|
+
if not validation.valid:
|
|
629
|
+
logger.warning(
|
|
630
|
+
f"⚠️ Rejected broken fix for {filename}: {validation.errors[0]}"
|
|
631
|
+
)
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
written = self._write_fixed_file(filename, fixed_content, resolved)
|
|
635
|
+
logger.info(f"📝 Updated: {written}")
|
|
636
|
+
wrote_any = True
|
|
637
|
+
|
|
638
|
+
return wrote_any
|