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.
Files changed (35) hide show
  1. selenium_agent/__init__.py +20 -0
  2. selenium_agent/agents/__init__.py +5 -0
  3. selenium_agent/agents/coder.py +505 -0
  4. selenium_agent/agents/definitions.py +152 -0
  5. selenium_agent/agents/healer.py +638 -0
  6. selenium_agent/agents/planner.py +279 -0
  7. selenium_agent/bdd/__init__.py +15 -0
  8. selenium_agent/bdd/gherkin_advisor.py +189 -0
  9. selenium_agent/bdd/templates.py +98 -0
  10. selenium_agent/cli.py +314 -0
  11. selenium_agent/core/__init__.py +3 -0
  12. selenium_agent/core/orchestrator.py +189 -0
  13. selenium_agent/scanner/__init__.py +3 -0
  14. selenium_agent/scanner/project_scanner.py +452 -0
  15. selenium_agent/selenium/__init__.py +6 -0
  16. selenium_agent/selenium/base_page.py +447 -0
  17. selenium_agent/selenium/driver_factory.py +222 -0
  18. selenium_agent/selenium/error_map.py +235 -0
  19. selenium_agent/selenium/locator_advisor.py +247 -0
  20. selenium_agent/selenium/locator_scanner.py +502 -0
  21. selenium_agent/utils/__init__.py +26 -0
  22. selenium_agent/utils/code_validator.py +96 -0
  23. selenium_agent/utils/config_manager.py +81 -0
  24. selenium_agent/utils/json_utils.py +103 -0
  25. selenium_agent/utils/llm.py +240 -0
  26. selenium_agent/utils/logger.py +37 -0
  27. selenium_agent/utils/paths.py +57 -0
  28. selenium_agent/utils/spec_writer.py +126 -0
  29. selenium_agent/utils/url_extractor.py +52 -0
  30. selenium_python_ai_agent-0.2.0.dist-info/METADATA +412 -0
  31. selenium_python_ai_agent-0.2.0.dist-info/RECORD +35 -0
  32. selenium_python_ai_agent-0.2.0.dist-info/WHEEL +5 -0
  33. selenium_python_ai_agent-0.2.0.dist-info/entry_points.txt +2 -0
  34. selenium_python_ai_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
  35. 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