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,502 @@
1
+ """
2
+ LOCATOR SCANNER
3
+ ===============
4
+ Opens a real browser, scans the page DOM, and returns BOTH CSS and XPath
5
+ for every interactive element — so LLM never has to guess.
6
+
7
+ CSS → preferred (faster, readable)
8
+ XPath → fallback (when CSS not expressive enough)
9
+ """
10
+
11
+ from selenium_agent.utils.logger import setup_logger
12
+
13
+ logger = setup_logger("LocatorScanner")
14
+
15
+ # ── JS: scan every interactive element → CSS + XPath ──────────────────────────
16
+ # NOTE: the leading `return` is load-bearing — Selenium's execute_script only
17
+ # hands the value back to Python if the script body explicitly returns it.
18
+ _SCAN_JS = """
19
+ return (function () {
20
+ // ── XPath builder (relative, attribute-based — never absolute) ────────────
21
+ function buildXPath(el) {
22
+ const tag = el.tagName.toLowerCase();
23
+
24
+ if (el.getAttribute('data-test'))
25
+ return '//' + tag + '[@data-test="' + el.getAttribute('data-test') + '"]';
26
+ if (el.getAttribute('data-testid'))
27
+ return '//' + tag + '[@data-testid="' + el.getAttribute('data-testid') + '"]';
28
+ if (el.getAttribute('data-cy'))
29
+ return '//' + tag + '[@data-cy="' + el.getAttribute('data-cy') + '"]';
30
+ if (el.id)
31
+ return '//' + tag + '[@id="' + el.id + '"]';
32
+ if (el.name)
33
+ return '//' + tag + '[@name="' + el.name + '"]';
34
+ if (el.getAttribute('aria-label'))
35
+ return '//' + tag + '[@aria-label="' + el.getAttribute('aria-label') + '"]';
36
+
37
+ const txt = (el.innerText || el.value || '').trim().slice(0, 40);
38
+ if (txt && (tag === 'button' || tag === 'a'))
39
+ return '//' + tag + '[normalize-space()="' + txt + '"]';
40
+
41
+ if (el.placeholder)
42
+ return '//' + tag + '[@placeholder="' + el.placeholder + '"]';
43
+
44
+ // Last resort: type attribute
45
+ if (el.type)
46
+ return '//' + tag + '[@type="' + el.type + '"]';
47
+
48
+ return null;
49
+ }
50
+
51
+ // ── CSS builder (priority: data-test > id > name > placeholder > type) ────
52
+ function buildCSS(el) {
53
+ const tag = el.tagName.toLowerCase();
54
+
55
+ if (el.getAttribute('data-test'))
56
+ return '[data-test="' + el.getAttribute('data-test') + '"]';
57
+ if (el.getAttribute('data-testid'))
58
+ return '[data-testid="' + el.getAttribute('data-testid') + '"]';
59
+ if (el.getAttribute('data-cy'))
60
+ return '[data-cy="' + el.getAttribute('data-cy') + '"]';
61
+ if (el.id)
62
+ return '#' + el.id;
63
+ if (el.name)
64
+ return tag + '[name="' + el.name + '"]';
65
+ if (el.placeholder)
66
+ return tag + '[placeholder="' + el.placeholder + '"]';
67
+ if (el.type && el.type !== 'text')
68
+ return tag + '[type="' + el.type + '"]';
69
+
70
+ return null;
71
+ }
72
+
73
+ // ── Uniqueness helpers — a locator that matches several elements is a trap ──
74
+ function cssCount(sel) {
75
+ try { return document.querySelectorAll(sel).length; } catch (e) { return 0; }
76
+ }
77
+ function xpathCount(xp) {
78
+ try {
79
+ return document.evaluate('count(' + xp + ')', document, null,
80
+ XPathResult.NUMBER_TYPE, null).numberValue;
81
+ } catch (e) { return 0; }
82
+ }
83
+
84
+ const INTERACTIVE = [
85
+ 'input', 'button', 'a[href]', 'select', 'textarea',
86
+ '[role="button"]', '[role="link"]', '[role="textbox"]',
87
+ '[role="checkbox"]', '[role="radio"]', '[type="submit"]'
88
+ ].join(',');
89
+
90
+ const results = [];
91
+ const seen = new Set();
92
+
93
+ document.querySelectorAll(INTERACTIVE).forEach(function (el) {
94
+ // Skip hidden elements
95
+ const rect = el.getBoundingClientRect();
96
+ if (rect.width === 0 && rect.height === 0) return;
97
+
98
+ let css = buildCSS(el);
99
+ let xpath = buildXPath(el);
100
+
101
+ const info = {
102
+ tag: el.tagName.toLowerCase(),
103
+ href: el.href || null,
104
+ options: el.tagName === 'SELECT'
105
+ ? [...el.options].slice(0, 12)
106
+ .map(o => o.textContent.trim()).filter(Boolean)
107
+ : null,
108
+ type: el.type || null,
109
+ id: el.id || null,
110
+ name: el.name || null,
111
+ placeholder: el.placeholder || null,
112
+ text: (el.innerText || el.value || '').trim().slice(0, 60) || null,
113
+ data_test: el.getAttribute('data-test') || null,
114
+ data_testid: el.getAttribute('data-testid') || null,
115
+ data_cy: el.getAttribute('data-cy') || null,
116
+ aria_label: el.getAttribute('aria-label') || null,
117
+ css: css,
118
+ xpath: xpath,
119
+ };
120
+
121
+ // ── Uniquify ambiguous selectors by scoping to an ancestor with an id ──
122
+ // e.g. 'input[type="submit"]' matching both a search icon and the real
123
+ // form button becomes '#create_customer input[type="submit"]'.
124
+ if ((css && cssCount(css) > 1) || (xpath && xpathCount(xpath) > 1)) {
125
+ let anc = el.parentElement;
126
+ while (anc && anc.tagName !== 'BODY') {
127
+ if (anc.id) {
128
+ if (css && cssCount(css) > 1) {
129
+ const scoped = '#' + anc.id + ' ' + css;
130
+ if (cssCount(scoped) === 1) { info.css = scoped; css = scoped; }
131
+ }
132
+ if (xpath && xpathCount(xpath) > 1 && xpath.indexOf('//') === 0) {
133
+ const sx = '//*[@id="' + anc.id + '"]//' + xpath.slice(2);
134
+ if (xpathCount(sx) === 1) { info.xpath = sx; xpath = sx; }
135
+ }
136
+ if ((!css || cssCount(css) === 1) && (!xpath || xpathCount(xpath) === 1))
137
+ break;
138
+ }
139
+ anc = anc.parentElement;
140
+ }
141
+ }
142
+
143
+ // Annotate whatever is STILL ambiguous so the LLM never guesses wrong
144
+ if (css) {
145
+ const n = cssCount(css);
146
+ if (n > 1) info.css_matches = n;
147
+ }
148
+ if (xpath) {
149
+ const n = xpathCount(xpath);
150
+ if (n > 1) info.xpath_matches = n;
151
+ }
152
+
153
+ const key = (css || '') + '|' + (xpath || '');
154
+ if (key !== '|' && !seen.has(key)) {
155
+ seen.add(key);
156
+ results.push(info);
157
+ }
158
+ });
159
+
160
+ // ── Text-bearing leaf elements (labels, displayed values, messages) ──
161
+ // Workflows often need to READ text from the page ("grab the username
162
+ // shown in the credentials section"). Capture a bounded set of short
163
+ // leaf text elements so the LLM has real locators for them too.
164
+ const TEXTY = 'p, span, label, h1, h2, h3, h4, h5, h6, td, th, li, code, pre, strong, b, div[id]';
165
+ let textCount = 0;
166
+ document.querySelectorAll(TEXTY).forEach(function (el) {
167
+ if (textCount >= 30) return;
168
+ if (el.children.length !== 0) return;
169
+ const txt = (el.textContent || '').trim();
170
+ if (txt.length < 3 || txt.length > 80) return;
171
+ const rect = el.getBoundingClientRect();
172
+ if (rect.width === 0 && rect.height === 0) return;
173
+
174
+ const tag = el.tagName.toLowerCase();
175
+ let css = null;
176
+ if (el.getAttribute('data-test'))
177
+ css = '[data-test="' + el.getAttribute('data-test') + '"]';
178
+ else if (el.getAttribute('data-testid'))
179
+ css = '[data-testid="' + el.getAttribute('data-testid') + '"]';
180
+ else if (el.id)
181
+ css = '#' + el.id;
182
+ else if (typeof el.className === 'string' && el.className.trim())
183
+ css = tag + '.' + el.className.trim().split(/\\s+/).slice(0, 2).join('.');
184
+
185
+ // A CSS that matches several elements is worse than none —
186
+ // reading text through it silently returns the WRONG element.
187
+ if (css && cssCount(css) !== 1) css = null;
188
+
189
+ let xpath = null;
190
+ if (txt.indexOf('"') === -1) {
191
+ // "LABEL: value" pattern → anchor on the stable label part so the
192
+ // locator survives when the value changes (status badges, etc.)
193
+ const colon = txt.indexOf(':');
194
+ if (colon > 0 && colon < 40) {
195
+ const prefixXp = '//' + tag + '[contains(normalize-space(), "'
196
+ + txt.slice(0, colon + 1) + '")]';
197
+ if (xpathCount(prefixXp) === 1) xpath = prefixXp;
198
+ }
199
+ if (!xpath) {
200
+ const exactXp = '//' + tag + '[normalize-space()="' + txt + '"]';
201
+ if (xpathCount(exactXp) === 1) xpath = exactXp;
202
+ }
203
+ }
204
+
205
+ if (!css && !xpath) return;
206
+ const key = 'txt|' + (css || '') + '|' + txt;
207
+ if (seen.has(key)) return;
208
+ seen.add(key);
209
+ textCount++;
210
+ results.push({
211
+ tag: tag, kind: 'text', href: null, type: null,
212
+ id: el.id || null, name: null, placeholder: null,
213
+ text: txt.slice(0, 60),
214
+ data_test: el.getAttribute('data-test') || null,
215
+ data_testid: el.getAttribute('data-testid') || null,
216
+ data_cy: null, aria_label: null,
217
+ css: css, xpath: xpath,
218
+ });
219
+ });
220
+
221
+ // ── Bot-protection detection ──
222
+ // A captcha (hCaptcha/reCAPTCHA/Turnstile) means automated flows that
223
+ // trigger it CANNOT pass — agents must report this instead of endlessly
224
+ // "fixing" locators.
225
+ if (document.querySelector(
226
+ 'iframe[src*="hcaptcha"], iframe[src*="recaptcha"], ' +
227
+ 'iframe[src*="turnstile"], [data-sitekey]')) {
228
+ results.push({
229
+ tag: 'iframe', kind: 'captcha', href: null, type: null,
230
+ id: null, name: null, placeholder: null,
231
+ text: 'CAPTCHA / bot protection active on this page',
232
+ data_test: null, data_testid: null, data_cy: null,
233
+ aria_label: null, css: null, xpath: null,
234
+ });
235
+ }
236
+
237
+ return results;
238
+ })();
239
+ """
240
+
241
+
242
+ def scan_page_locators(url: str, headless: bool = True, max_wait: int = 25) -> list[dict]:
243
+ """
244
+ Open browser, navigate to url, scan DOM, return elements with real CSS + XPath.
245
+
246
+ SPA-aware: client-side apps (React/Vue/Angular) render the form long after
247
+ document.readyState is 'complete' — some behind boot animations where
248
+ elements exist in the DOM but are still zero-sized. So instead of one shot,
249
+ the scan JS is POLLED every second until visible interactive elements
250
+ appear (up to max_wait seconds).
251
+
252
+ Returns [] on any failure — callers handle gracefully.
253
+ """
254
+ try:
255
+ from selenium_agent.selenium.driver_factory import DriverFactory
256
+ driver = DriverFactory.create(browser="chrome", headless=headless)
257
+ except Exception as e:
258
+ logger.warning(f"⚠️ Browser launch failed for scan: {e}")
259
+ return []
260
+
261
+ try:
262
+ logger.info(f"🔍 Scanning DOM: {url}")
263
+ driver.get(url)
264
+
265
+ from selenium.webdriver.support.ui import WebDriverWait
266
+ import time
267
+
268
+ # Wait for full page load
269
+ WebDriverWait(driver, 10).until(
270
+ lambda d: d.execute_script("return document.readyState") == "complete"
271
+ )
272
+
273
+ # Wait for redirect to settle — URL stops changing
274
+ initial_url = driver.current_url
275
+ time.sleep(1.5)
276
+ final_url = driver.current_url
277
+ if final_url != initial_url:
278
+ logger.info(f"🔄 Redirected to: {final_url}")
279
+ WebDriverWait(driver, 10).until(
280
+ lambda d: d.execute_script("return document.readyState") == "complete"
281
+ )
282
+
283
+ # Poll until VISIBLE interactive elements render (SPA hydration,
284
+ # boot animations, lazy data fetches)
285
+ deadline = time.time() + max_wait
286
+ elements: list[dict] = []
287
+ while time.time() < deadline:
288
+ elements = driver.execute_script(_SCAN_JS) or []
289
+ if elements:
290
+ # settle pass — catch elements that render moments later
291
+ time.sleep(1.0)
292
+ more = driver.execute_script(_SCAN_JS) or []
293
+ if len(more) > len(elements):
294
+ elements = more
295
+ break
296
+ time.sleep(1.0)
297
+
298
+ if not elements:
299
+ logger.warning(f"⚠️ No visible interactive elements after {max_wait}s on: {driver.current_url}")
300
+ logger.info(f"✅ {len(elements)} interactive elements found on: {driver.current_url}")
301
+ return elements
302
+
303
+ except Exception as e:
304
+ logger.warning(f"⚠️ DOM scan failed: {e}")
305
+ return []
306
+
307
+ finally:
308
+ try:
309
+ driver.quit()
310
+ except Exception:
311
+ pass
312
+
313
+
314
+ def rank_links_by_relevance(links: list[dict], instruction: str) -> list[str]:
315
+ """
316
+ Order candidate links so the ones RELEVANT to the instruction come first.
317
+
318
+ Exploring the first N links in document order wastes the budget on nav
319
+ noise (search, about-us, ...) while the page the flow actually needs
320
+ (e.g. /account/register for a sign-up instruction) is never scanned.
321
+ Scores each link by how many instruction words appear in its visible
322
+ text or href; ties keep document order.
323
+
324
+ links: [{"href": ..., "text": ...}] → ordered list of hrefs
325
+ """
326
+ import re
327
+
328
+ stop = {"the", "and", "for", "with", "then", "that", "this", "page",
329
+ "open", "click", "verify", "form", "button", "link", "fill",
330
+ "random", "generated", "runtime", "unique", "finally", "was"}
331
+ tokens = {
332
+ w for w in re.findall(r"[a-z]+", instruction.lower())
333
+ if len(w) >= 3 and w not in stop
334
+ }
335
+
336
+ def score(link: dict) -> int:
337
+ haystack = f"{link.get('text') or ''} {link.get('href') or ''}".lower()
338
+ return sum(1 for t in tokens if t in haystack)
339
+
340
+ ranked = sorted(
341
+ enumerate(links),
342
+ key=lambda pair: (-score(pair[1]), pair[0]),
343
+ )
344
+ return [link.get("href") for _, link in ranked]
345
+
346
+
347
+ def scan_site_locators(
348
+ url: str,
349
+ headless: bool = True,
350
+ max_extra_pages: int = 3,
351
+ instruction: str = "",
352
+ ) -> dict[str, list[dict]]:
353
+ """
354
+ Bounded same-origin exploration (Playwright-planner style):
355
+ scan the target URL, then follow up to `max_extra_pages` same-origin
356
+ links discovered on it and scan those pages too. When an instruction
357
+ is given, links relevant to it are explored first.
358
+
359
+ Returns {page_url: [elements]} — the target URL is always first.
360
+ Pages behind auth simply return whatever the redirect target renders.
361
+ """
362
+ from urllib.parse import urlparse
363
+
364
+ results: dict[str, list[dict]] = {}
365
+ elements = scan_page_locators(url, headless=headless)
366
+ results[url] = elements
367
+ if not elements or max_extra_pages <= 0:
368
+ return results
369
+
370
+ origin = urlparse(url).netloc
371
+ seen = {url.rstrip("/"), url.rstrip("/") + "/"}
372
+
373
+ def collect_links(page_elements: list[dict]) -> list[dict]:
374
+ found = []
375
+ for el in page_elements:
376
+ href = el.get("href") or ""
377
+ if not href.startswith("http"):
378
+ continue
379
+ clean = href.split("#")[0].rstrip("/")
380
+ if urlparse(href).netloc == origin and clean and clean not in seen:
381
+ seen.add(clean)
382
+ found.append({"href": clean, "text": el.get("text") or ""})
383
+ return found
384
+
385
+ # Multi-hop, relevance-first exploration: links discovered on explored
386
+ # pages join the pool, so a page one hop deeper (e.g. a register form
387
+ # linked only from the login page) is still reachable within budget.
388
+ pool = collect_links(elements)
389
+ for _ in range(max_extra_pages):
390
+ if not pool:
391
+ break
392
+ if instruction:
393
+ best = rank_links_by_relevance(pool, instruction)[0]
394
+ else:
395
+ best = pool[0]["href"]
396
+ pool = [c for c in pool if c["href"] != best]
397
+
398
+ logger.info(f"🧭 Exploring same-origin page: {best}")
399
+ extra_elements = scan_page_locators(best, headless=headless)
400
+ if extra_elements:
401
+ results[best] = extra_elements
402
+ pool.extend(collect_links(extra_elements))
403
+
404
+ return results
405
+
406
+
407
+ def format_site_for_llm(site: dict[str, list[dict]], context: str = "general") -> str:
408
+ """Format multi-page scan results, one labeled block per page."""
409
+ blocks = []
410
+ for page_url, elements in site.items():
411
+ if not elements:
412
+ continue
413
+ block = format_for_llm(elements, context=context)
414
+ blocks.append(f"═══ PAGE: {page_url} ═══\n{block}")
415
+ return "\n\n".join(blocks)
416
+
417
+
418
+ def format_for_llm(elements: list[dict], context: str = "general") -> str:
419
+ """
420
+ Format scanned elements into a clear prompt block for LLM.
421
+ context: 'planning' | 'healing' | 'general'
422
+ """
423
+ if not elements:
424
+ return ""
425
+
426
+ header = {
427
+ "planning": "🔍 REAL DOM LOCATORS — scanned before generating plan. Use ONLY these:",
428
+ "healing": "🔍 REAL DOM LOCATORS — re-scanned from live page. Fix failed locators using these:",
429
+ "general": "🔍 REAL DOM LOCATORS (from actual browser scan):",
430
+ }.get(context, "🔍 REAL DOM LOCATORS:")
431
+
432
+ lines = [header, ""]
433
+
434
+ if any(el.get("kind") == "captcha" for el in elements):
435
+ lines += [
436
+ "🚫 CAPTCHA / BOT PROTECTION IS ACTIVE ON THIS PAGE.",
437
+ " Flows that trigger it (form submits, logins, sign-ups) CANNOT pass",
438
+ " with automation and MUST NOT be bypassed. Do not keep fixing",
439
+ " locators — report this as the root cause instead.",
440
+ "",
441
+ ]
442
+
443
+ for el in elements:
444
+ if el.get("kind") == "captcha":
445
+ continue
446
+ label = (
447
+ el.get("placeholder") or
448
+ el.get("text") or
449
+ el.get("aria_label") or
450
+ el.get("data_test") or
451
+ el.get("id") or
452
+ el.get("name") or
453
+ f"<{el['tag']}>"
454
+ )
455
+ css = el.get("css")
456
+ xpath = el.get("xpath")
457
+
458
+ line = f" [{label[:40]}]"
459
+ if css:
460
+ line += f" CSS → By.CSS_SELECTOR, '{css}'"
461
+ if el.get("css_matches"):
462
+ line += f" ⚠️ NOT UNIQUE (matches {el['css_matches']} elements — use XPATH)"
463
+ if xpath:
464
+ line += f" | XPATH → By.XPATH, '{xpath}'"
465
+ if el.get("xpath_matches"):
466
+ line += f" ⚠️ NOT UNIQUE (matches {el['xpath_matches']} elements)"
467
+ if el.get("options"):
468
+ line += (f" OPTIONS(sample): {el['options'][:8]}"
469
+ f"{' …' if len(el['options']) > 8 else ''}"
470
+ f" ← use option text EXACTLY as listed")
471
+ lines.append(line)
472
+
473
+ lines += [
474
+ "",
475
+ "RULES:",
476
+ " • Prefer CSS over XPath — but NEVER use a CSS marked NOT UNIQUE",
477
+ " for a single element (it silently returns the WRONG element);",
478
+ " use that element's XPATH instead",
479
+ " • Use XPath only when CSS cannot express the condition",
480
+ " • NEVER invent locators not listed above",
481
+ "",
482
+ ]
483
+ return "\n".join(lines)
484
+
485
+
486
+ def extract_failed_locator_value(pytest_output: str) -> str | None:
487
+ """
488
+ Parse pytest output and extract the locator value that caused
489
+ NoSuchElementException or TimeoutException.
490
+ """
491
+ import re
492
+ patterns = [
493
+ r'Message:\s*Unable to locate element:\s*["{]?([^"}\n]+)',
494
+ r'NoSuchElementException.*?["\']([^"\']+)["\']',
495
+ r'TimeoutException.*?["\']([^"\']+)["\']',
496
+ r'find_element.*?["\']([^"\']+)["\']',
497
+ ]
498
+ for pattern in patterns:
499
+ m = re.search(pattern, pytest_output, re.IGNORECASE | re.DOTALL)
500
+ if m:
501
+ return m.group(1).strip()
502
+ return None
@@ -0,0 +1,26 @@
1
+ from selenium_agent.utils.logger import setup_logger
2
+ from selenium_agent.utils.llm import (
3
+ DEFAULT_PROVIDER,
4
+ create_llm_client,
5
+ format_missing_api_key_error,
6
+ get_api_key_env_var,
7
+ get_default_model,
8
+ normalize_provider,
9
+ resolve_api_key,
10
+ )
11
+ from selenium_agent.utils.paths import get_output_root, resolve_input_path, safe_output_path
12
+ from selenium_agent.utils import config_manager
13
+
14
+ __all__ = [
15
+ "DEFAULT_PROVIDER",
16
+ "create_llm_client",
17
+ "format_missing_api_key_error",
18
+ "get_api_key_env_var",
19
+ "get_default_model",
20
+ "normalize_provider",
21
+ "resolve_api_key",
22
+ "setup_logger",
23
+ "get_output_root",
24
+ "resolve_input_path",
25
+ "safe_output_path",
26
+ ]
@@ -0,0 +1,96 @@
1
+ """
2
+ CODE VALIDATOR
3
+ ==============
4
+ Static validation of LLM-generated Python before it touches disk.
5
+
6
+ Catches, without running a browser:
7
+ - syntax errors (ast.parse)
8
+ - forbidden patterns (time.sleep, find_element in tests, By in test files)
9
+
10
+ Used by the Coder (validate before save, retry on failure) and the
11
+ Healer (never overwrite a working file with a syntactically broken fix).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import re
18
+ from dataclasses import dataclass, field
19
+
20
+
21
+ @dataclass
22
+ class ValidationResult:
23
+ filename: str
24
+ valid: bool
25
+ errors: list[str] = field(default_factory=list)
26
+ warnings: list[str] = field(default_factory=list)
27
+
28
+
29
+ def _is_test_file(filename: str) -> bool:
30
+ name = filename.replace("\\", "/").rsplit("/", 1)[-1]
31
+ return name.startswith("test_") and name.endswith(".py")
32
+
33
+
34
+ def validate_python(filename: str, content: str) -> ValidationResult:
35
+ """Validate one generated/fixed Python file. Feature files pass through."""
36
+ result = ValidationResult(filename=filename, valid=True)
37
+
38
+ if not filename.endswith(".py"):
39
+ return result
40
+
41
+ try:
42
+ ast.parse(content)
43
+ except SyntaxError as exc:
44
+ result.valid = False
45
+ result.errors.append(
46
+ f"SyntaxError at line {exc.lineno}: {exc.msg}\n"
47
+ f" {(exc.text or '').rstrip()}"
48
+ )
49
+ return result # no point pattern-checking broken code
50
+
51
+ if re.search(r"\btime\.sleep\s*\(", content):
52
+ result.warnings.append("time.sleep() found — use fluent_wait/explicit waits instead")
53
+
54
+ if _is_test_file(filename):
55
+ if re.search(r"from\s+selenium\.webdriver\.common\.by\s+import\s+By", content):
56
+ result.warnings.append("By import in a test file — locators belong in page objects")
57
+ if re.search(r"\bBy\.[A-Z_]+\s*,", content):
58
+ result.warnings.append("Raw locator tuple in a test file — move it to the page object")
59
+ # The driver fixture is deterministic framework scaffolding (conftest.py).
60
+ # LLM-invented fixtures/drivers are the #1 source of collection errors.
61
+ if re.search(r"def\s+driver\w*\s*\(", content):
62
+ result.valid = False
63
+ result.errors.append(
64
+ "Test file defines its own driver fixture — conftest.py already "
65
+ "provides `driver`; test functions must just accept it as a parameter"
66
+ )
67
+ if "DriverFactory" in content:
68
+ result.valid = False
69
+ result.errors.append(
70
+ "Test file imports/uses DriverFactory — drivers come only from "
71
+ "the conftest.py `driver` fixture"
72
+ )
73
+ if re.search(r"pytest\.skip\s*\(", content):
74
+ result.valid = False
75
+ result.errors.append(
76
+ "Generated tests must not contain pytest.skip placeholders — "
77
+ "implement every step with the best available locator; the "
78
+ "healer verifies and corrects locators against the live DOM"
79
+ )
80
+
81
+ return result
82
+
83
+
84
+ def validate_files(files: list[dict]) -> list[ValidationResult]:
85
+ """Validate a list of {"filename": ..., "content": ...} dicts."""
86
+ return [validate_python(f.get("filename", ""), f.get("content", "")) for f in files]
87
+
88
+
89
+ def format_errors(results: list[ValidationResult]) -> str:
90
+ """Human/LLM-readable summary of validation failures."""
91
+ lines = []
92
+ for r in results:
93
+ if not r.valid:
94
+ lines.append(f"❌ {r.filename}:")
95
+ lines.extend(f" {e}" for e in r.errors)
96
+ return "\n".join(lines)