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,20 @@
1
+ """
2
+ Selenium Python AI Agent
3
+ ========================
4
+ A multi-agent framework to plan, code and heal Selenium Python tests using Anthropic or OpenAI.
5
+
6
+ Usage (Python Library):
7
+ from selenium_agent import SeleniumAgent
8
+
9
+ agent = SeleniumAgent(provider="openai", api_key="your-openai-key")
10
+ agent.run("test login page of github.com")
11
+
12
+ Usage (CLI):
13
+ selenium-agent --provider openai "test login page of github.com" --api-key YOUR_KEY
14
+ """
15
+
16
+ from selenium_agent.core.orchestrator import Orchestrator as SeleniumAgent
17
+
18
+ __version__ = "0.2.0"
19
+ __author__ = "Ankit Tripathi"
20
+ __all__ = ["SeleniumAgent"]
@@ -0,0 +1,5 @@
1
+ from selenium_agent.agents.planner import PlannerAgent
2
+ from selenium_agent.agents.coder import CoderAgent
3
+ from selenium_agent.agents.healer import HealerAgent
4
+
5
+ __all__ = ["PlannerAgent", "CoderAgent", "HealerAgent"]
@@ -0,0 +1,505 @@
1
+ """
2
+ CODER (GENERATOR) AGENT
3
+ =======================
4
+ Works like Playwright's generator agent, for Selenium Python:
5
+
6
+ 1. Consumes a structured plan (fresh from the Planner or loaded from
7
+ specs/<slug>.json) built from REAL scanned locators.
8
+ 2. Generates Page Object Model code (pytest or pytest-bdd).
9
+ 3. Self-verifies before saving: every file is syntax-checked (ast) and
10
+ architecture-checked (no By imports / raw locators in test files);
11
+ on failure the LLM gets ONE repair round with the exact errors.
12
+ 4. Adapts to an existing project's structure, base class, and import
13
+ style when a ProjectProfile is provided.
14
+ """
15
+
16
+ import json
17
+ import re
18
+ from pathlib import Path
19
+
20
+ from selenium_agent.utils.logger import setup_logger
21
+ from selenium_agent.utils.llm import create_llm_client, DEFAULT_PROVIDER, get_default_model
22
+ from selenium_agent.utils.json_utils import extract_json_object, LLMJSONError
23
+ from selenium_agent.utils.paths import safe_output_path
24
+ from selenium_agent.utils.code_validator import validate_files, format_errors
25
+ from selenium_agent.selenium.locator_advisor import LocatorAdvisor
26
+ from selenium_agent.bdd.gherkin_advisor import GherkinAdvisor
27
+
28
+ logger = setup_logger("CoderAgent")
29
+
30
+ EXACT_API_REFERENCE = """
31
+ IMPORTS:
32
+ from selenium.webdriver.common.by import By
33
+ from selenium_agent.selenium.base_page import BasePage
34
+ from selenium_agent.selenium.driver_factory import DriverFactory
35
+
36
+ ══ PAGE OBJECT ARCHITECTURE — MANDATORY ══
37
+
38
+ One class per page. NEVER mix locators from different pages into one class.
39
+ Class names and locators come from the plan — never hardcoded.
40
+
41
+ class SomePage(BasePage):
42
+ URL = '<url from plan>'
43
+ ELEMENT_NAME = (By.CSS_SELECTOR, '<selector from plan>')
44
+ ANOTHER_ELEMENT = (By.ID, '<id from plan>')
45
+
46
+ # Methods optional — for complex reusable actions only
47
+
48
+ FORBIDDEN: putting page B's locators inside page A's class.
49
+
50
+ ══ FLUENT WAIT — use for every interaction ══
51
+
52
+ page.fluent_wait(locator, 'visible') ← inputs, text (DEFAULT)
53
+ page.fluent_wait(locator, 'clickable') ← all buttons and links
54
+ page.fluent_wait(locator, 'invisible') ← loaders/spinners
55
+ page.fluent_wait(locator, 'present') ← hidden inputs
56
+
57
+ ══ NAVIGATION — wait_for_url() after every click that changes page ══
58
+
59
+ page.click(page.SUBMIT_BTN)
60
+ page.wait_for_url('expected-url-fragment') ← ALWAYS after navigation
61
+ next_page = NextPage(driver)
62
+ next_page.fluent_wait(next_page.SOME_ELEMENT, 'visible')
63
+
64
+ ══ FORM FILLING — always use page.safe_type() ══
65
+
66
+ page.safe_type(page.FIRST_NAME_INPUT, "John")
67
+ ← built into BasePage: types, verifies, falls back to JS + React events
68
+
69
+ ══ AVAILABLE BasePage METHODS ══
70
+
71
+ page.open(url) page.click(locator)
72
+ page.type(locator, text) page.get_text(locator)
73
+ page.find(locator) page.find_all(locator)
74
+ page.get_url() page.get_title()
75
+ page.is_visible(locator) page.is_present(locator)
76
+ page.wait_for_url(partial) page.wait_for_invisible(locator)
77
+ page.scroll_to(locator) page.execute_js(script, *args)
78
+ page.safe_type(locator, text) ← JS-fallback form input
79
+ page.select_by_text(locator, text) page.hover(locator)
80
+
81
+ ══ READING VALUES FROM THE PAGE ══
82
+
83
+ get_text() returns the FULL text of the element, including any label prefix.
84
+ When a "LABEL: value" element is read to USE its value, strip the label:
85
+
86
+ raw = page.get_text(page.SHOWN_USERNAME) # "USERNAME: testuser"
87
+ username = raw.split(':', 1)[-1].strip() # "testuser"
88
+
89
+ ══ FILLING FORMS COMPLETELY ══
90
+
91
+ When the DOM scan of a form page lists MORE inputs/selects/date fields
92
+ than the instruction names, fill ALL of them with sensible values —
93
+ apps reject submissions with missing required fields, often silently
94
+ (the form just re-renders). "Fill the form" means the WHOLE form.
95
+
96
+ ══ UNIQUE TEST DATA ══
97
+
98
+ When a flow CREATES something (an account, a record, any entity),
99
+ hardcoded data collides with previous runs ("email already taken").
100
+ Generate unique values at runtime in the test:
101
+
102
+ import uuid
103
+ unique = uuid.uuid4().hex[:8]
104
+ email = "qa." + unique + "@example.com"
105
+ password = "Xk9#" + unique + "!Qz" # strong AND unique
106
+ first_name = "Test" + unique
107
+
108
+ PASSWORDS: never use common patterns (Password@123, Test@1234, ...) —
109
+ browsers and apps reject passwords found in breach lists ("this password
110
+ has appeared in a data leak"). Always embed the runtime-unique suffix.
111
+
112
+ ══ ASSERTING OUTCOMES ══
113
+
114
+ Text captured in the DOM scan is the page's state BEFORE any action
115
+ (e.g. a status element showing "STANDBY" before login).
116
+ NEVER assert pre-action text as the expected outcome.
117
+ Assert what the scenario expects AFTER the action: a status element's text
118
+ CHANGES, a success/error message APPEARS, a new element becomes visible.
119
+ Many SPAs never navigate — verify in-page indicators, not URLs, unless the
120
+ plan explicitly says the URL changes.
121
+
122
+ FORBIDDEN: find_element(), time.sleep(), get_driver(), DriverFactory.get_driver()
123
+
124
+ ══ PAGES BEHIND LOGIN — NO SKIP PLACEHOLDERS, EVER ══
125
+
126
+ Auth-gated pages often can't be DOM-scanned up front, so the plan may
127
+ lack locators for them. In that case write the MOST LIKELY locator based
128
+ on the app's visible conventions (e.g. if scanned pages use data-test
129
+ attributes, inner pages almost certainly do too) — the Healer verifies
130
+ and corrects every locator against the live DOM at run time.
131
+
132
+ FORBIDDEN: pytest.skip(...) placeholders, TODO steps, empty step bodies.
133
+ A skipped test proves nothing; a best-effort test gets healed to green.
134
+
135
+ ══ DRIVER FIXTURE — PROVIDED BY THE FRAMEWORK ══
136
+
137
+ conftest.py (auto-generated) already defines the `driver` fixture.
138
+ Test functions simply accept it as a parameter:
139
+
140
+ def test_something(driver):
141
+ page = SomePage(driver)
142
+ ...
143
+
144
+ FORBIDDEN in test files:
145
+ - defining any driver fixture
146
+ - importing or calling DriverFactory
147
+ - creating/quitting drivers manually
148
+ """
149
+
150
+ CONFTEST_CONTENT = '''"""conftest.py — Auto-generated by selenium-python-ai-agent"""
151
+ import sys, os
152
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
153
+
154
+ import pytest
155
+ from selenium_agent.selenium.driver_factory import DriverFactory
156
+
157
+ HEADLESS = __HEADLESS__
158
+
159
+ # Markers/tags used by the generated tests — registered automatically so
160
+ # `pytest -m <marker>` works cleanly, no pytest.ini needed.
161
+ MARKERS = __MARKERS__
162
+
163
+
164
+ def pytest_configure(config):
165
+ for marker in MARKERS:
166
+ config.addinivalue_line("markers", f"{marker}: tagged by selenium-agent")
167
+
168
+
169
+ @pytest.fixture(scope="function")
170
+ def driver():
171
+ """Canonical driver fixture — provided by the framework, never by
172
+ generated test files (deterministic scaffolding beats regeneration)."""
173
+ d = DriverFactory.create(browser="chrome", headless=HEADLESS)
174
+ yield d
175
+ d.quit()
176
+
177
+
178
+ @pytest.hookimpl(hookwrapper=True)
179
+ def pytest_runtest_makereport(item, call):
180
+ """On failure, report the URL the browser was on — the healer re-scans
181
+ that exact page instead of guessing from the base URL."""
182
+ outcome = yield
183
+ report = outcome.get_result()
184
+ if report.when == "call" and report.failed:
185
+ driver = item.funcargs.get("driver")
186
+ if driver is not None:
187
+ try:
188
+ sys.stderr.write(f"\\nFAILURE_URL: {driver.current_url}\\n")
189
+ errors = driver.execute_script(
190
+ "return [...document.querySelectorAll("
191
+ "'[class*=alert],[class*=error],[class*=invalid],[role=alert]')]"
192
+ ".map(e => e.textContent.trim()).filter(t => t).slice(0, 10)"
193
+ ) or []
194
+ if errors:
195
+ sys.stderr.write(f"FAILURE_ERRORS: {errors}\\n")
196
+ text = driver.execute_script("return document.body.innerText") or ""
197
+ text = " | ".join(text.split())[:800]
198
+ sys.stderr.write(f"FAILURE_PAGE_TEXT: {text}\\n")
199
+ except Exception:
200
+ pass
201
+ '''
202
+
203
+ CODER_SYSTEM_PROMPT_PYTEST = f"""
204
+ You are an expert Selenium Python engineer. Generate pytest Page Object Model code.
205
+
206
+ {EXACT_API_REFERENCE}
207
+
208
+ {LocatorAdvisor.get_priority_guide()}
209
+
210
+ RULES:
211
+ 1. One class per page — names and selectors from plan only, never hardcoded
212
+ 2. page.safe_type() for ALL form inputs
213
+ 3. wait_for_url() after every navigation click
214
+ 4. fluent_wait() before every element interaction
215
+ 5. headless from plan JSON — never hardcode True/False
216
+ 6. No sys.path — conftest.py handles it
217
+ 7. No By imports in test files — only in page objects
218
+
219
+ Output valid JSON only, no markdown:
220
+ {{"files": [
221
+ {{"filename": "pages/<name>_page.py", "content": "..."}},
222
+ {{"filename": "tests/test_<name>.py", "content": "..."}}
223
+ ]}}
224
+ """
225
+
226
+ CODER_SYSTEM_PROMPT_BDD = f"""
227
+ You are an expert Selenium Python BDD engineer. Generate pytest-bdd Page Object Model code.
228
+
229
+ {EXACT_API_REFERENCE}
230
+ {LocatorAdvisor.get_priority_guide()}
231
+ {GherkinAdvisor.get_guide()}
232
+
233
+ RULES:
234
+ 1. One class per page — names and selectors from plan only
235
+ 2. page.safe_type() for ALL form inputs
236
+ 3. wait_for_url() after every navigation
237
+ 4. headless from plan JSON
238
+ 5. Step files must start with test_
239
+
240
+ Output valid JSON only, no markdown:
241
+ {{"files": [
242
+ {{"filename": "features/<name>.feature", "content": "..."}},
243
+ {{"filename": "pages/<name>_page.py", "content": "..."}},
244
+ {{"filename": "step_definitions/test_<name>_steps.py", "content": "..."}}
245
+ ]}}
246
+ """
247
+
248
+ REPAIR_PROMPT = """
249
+ The code you generated has validation errors. Fix them and return the
250
+ COMPLETE corrected set of files in the same JSON format.
251
+
252
+ VALIDATION ERRORS:
253
+ {errors}
254
+
255
+ YOUR PREVIOUS OUTPUT:
256
+ {previous}
257
+ """
258
+
259
+
260
+ class CoderAgent:
261
+ def __init__(self, api_key: str, output_dir: str = "generated_tests",
262
+ provider: str = DEFAULT_PROVIDER, model: str | None = None):
263
+ resolved_model = model or get_default_model(provider)
264
+ self.client = create_llm_client(
265
+ provider=provider, api_key=api_key, model=resolved_model
266
+ )
267
+ self.output_dir = output_dir
268
+
269
+ def code(self, plan: dict, project_profile=None) -> list[str]:
270
+ mode = plan.get("mode", "pytest")
271
+ logger.info(f"💻 Generating [{mode.upper()}] code (headless={plan.get('headless', False)})...")
272
+
273
+ system_prompt = CODER_SYSTEM_PROMPT_BDD if mode == "bdd" else CODER_SYSTEM_PROMPT_PYTEST
274
+ user_prompt = self._build_user_prompt(plan, project_profile, mode)
275
+ max_tokens = self._token_budget(plan)
276
+
277
+ raw = self.client.generate_text(
278
+ system_prompt=system_prompt,
279
+ user_prompt=user_prompt,
280
+ max_tokens=max_tokens,
281
+ json_mode=True,
282
+ )
283
+ try:
284
+ result = extract_json_object(raw)
285
+ except LLMJSONError:
286
+ logger.warning("⚠️ Generator JSON unparseable — retrying once with strict reminder")
287
+ raw = self.client.generate_text(
288
+ system_prompt=system_prompt,
289
+ user_prompt=user_prompt + "\n\nIMPORTANT: respond with ONLY the JSON object. "
290
+ "Escape all quotes and newlines inside file contents.",
291
+ max_tokens=max_tokens,
292
+ json_mode=True,
293
+ )
294
+ result = extract_json_object(raw)
295
+ files = result.get("files", [])
296
+ if not files:
297
+ raise ValueError("Coder LLM returned no files")
298
+
299
+ # ── Completeness: a plan must yield page objects AND runnable tests ──
300
+ missing = self._missing_file_kinds(files, mode)
301
+ if missing:
302
+ logger.warning(f"⚠️ Generator output incomplete — missing: {', '.join(missing)} — retry")
303
+ raw = self.client.generate_text(
304
+ system_prompt=system_prompt,
305
+ user_prompt=user_prompt + (
306
+ f"\n\nYOUR PREVIOUS RESPONSE WAS INCOMPLETE — it was missing: "
307
+ f"{', '.join(missing)}.\n"
308
+ f"Return the COMPLETE set of files (page objects AND test files) "
309
+ f"in one JSON object."
310
+ ),
311
+ max_tokens=max_tokens,
312
+ json_mode=True,
313
+ )
314
+ files = extract_json_object(raw).get("files", files)
315
+ still_missing = self._missing_file_kinds(files, mode)
316
+ if still_missing:
317
+ raise ValueError(
318
+ f"Generator failed to produce a complete test suite — "
319
+ f"missing: {', '.join(still_missing)}"
320
+ )
321
+
322
+ # ── Self-verification: syntax + architecture, one repair round ──
323
+ files = self._sanitize(files)
324
+ validations = validate_files(files)
325
+ broken = [v for v in validations if not v.valid]
326
+ if broken:
327
+ logger.warning(f"⚠️ {len(broken)} generated file(s) failed validation — repair round")
328
+ raw = self.client.generate_text(
329
+ system_prompt=system_prompt,
330
+ user_prompt=user_prompt + REPAIR_PROMPT.format(
331
+ errors=format_errors(validations),
332
+ previous=json.dumps(result)[:12000],
333
+ ),
334
+ max_tokens=max_tokens,
335
+ json_mode=True,
336
+ )
337
+ files = self._sanitize(extract_json_object(raw).get("files", files))
338
+ still_broken = [v for v in validate_files(files) if not v.valid]
339
+ if still_broken:
340
+ raise ValueError(
341
+ "Generated code failed validation after repair:\n"
342
+ + format_errors(still_broken)
343
+ )
344
+
345
+ for v in validate_files(files):
346
+ for w in v.warnings:
347
+ logger.warning(f"⚠️ {v.filename}: {w}")
348
+
349
+ # ── Save ──
350
+ saved = []
351
+ target_url = plan.get("url")
352
+ for file_info in files:
353
+ content = file_info["content"]
354
+ if target_url:
355
+ content = self._force_correct_url(content, target_url)
356
+ filepath = safe_output_path(self.output_dir, file_info["filename"])
357
+ filepath.parent.mkdir(parents=True, exist_ok=True)
358
+ filepath.write_text(content, encoding="utf-8")
359
+ saved.append(str(filepath))
360
+ logger.info(f"✅ Created: {filepath}")
361
+
362
+ self._write_conftest(project_profile, headless=plan.get("headless", False),
363
+ markers=self._collect_markers(plan))
364
+ return saved
365
+
366
+ @staticmethod
367
+ def _collect_markers(plan: dict) -> list[str]:
368
+ """Union of plan-level pytest markers and BDD scenario tags."""
369
+ markers = set(plan.get("pytest_markers") or [])
370
+ for scenario in (plan.get("scenarios") or plan.get("test_scenarios") or []):
371
+ for tag in scenario.get("tags") or []:
372
+ markers.add(str(tag).lstrip("@"))
373
+ markers.add("smoke") # agents tag critical-path tests with @smoke by convention
374
+ return sorted(markers)
375
+
376
+ # ── Prompt building ────────────────────────────────────────────────
377
+
378
+ def _build_user_prompt(self, plan: dict, project_profile, mode: str) -> str:
379
+ project_context = ""
380
+ if project_profile:
381
+ base_import_hint = ""
382
+ if getattr(project_profile, "base_page_import", ""):
383
+ base_import_hint = (
384
+ f"IMPORTANT — this project has its OWN base page class. "
385
+ f"Page objects MUST use it instead of selenium_agent's:\n"
386
+ f" from {project_profile.base_page_import} "
387
+ f"import {project_profile.base_page_class}\n"
388
+ f" class SomePage({project_profile.base_page_class}): ...\n"
389
+ f"Only call methods that exist on that base class "
390
+ f"(see the sample page object below for its style).\n"
391
+ )
392
+ project_context = (
393
+ f"\n\nFit into existing project:\n"
394
+ f"{project_profile.to_llm_context()}\n"
395
+ f"Pages dir : {project_profile.pages_dir}/\n"
396
+ f"Tests dir : {project_profile.tests_dir}/\n"
397
+ f"{base_import_hint}"
398
+ )
399
+
400
+ url_warning = ""
401
+ if plan.get("url"):
402
+ url_warning = (
403
+ f"\n\n🚨 MANDATORY URL: {plan['url']}\n"
404
+ f"Use this as the page URL constant. NEVER use example.com.\n"
405
+ )
406
+
407
+ return (
408
+ f"Generate Selenium Python {mode} code for:\n"
409
+ f"{json.dumps(plan, indent=2)}"
410
+ f"{url_warning}"
411
+ f"{project_context}"
412
+ )
413
+
414
+ def _token_budget(self, plan: dict) -> int:
415
+ """
416
+ Output-size budget from the plan's STRUCTURE (pages, scenarios,
417
+ locators) — never from keywords: common English words like "then"
418
+ or "navigate" appear in almost every plan and mislabel simple
419
+ flows as complex. max_tokens is a cap, not a target, but the
420
+ label should still be honest.
421
+ """
422
+ scenarios = len(plan.get("test_scenarios") or plan.get("scenarios", []))
423
+ page_objects = len(plan.get("page_objects_needed", []))
424
+ locators = len(plan.get("locators", []))
425
+ is_complex = scenarios > 2 or page_objects > 2 or locators > 12
426
+ max_tokens = 12000 if is_complex else 6000
427
+ logger.info(
428
+ f"📐 Scenarios: {scenarios} | Pages: {page_objects} | "
429
+ f"Locators: {locators} | complex: {is_complex} | max_tokens: {max_tokens}"
430
+ )
431
+ return max_tokens
432
+
433
+ # ── Post-processing ────────────────────────────────────────────────
434
+
435
+ @staticmethod
436
+ def _missing_file_kinds(files: list[dict], mode: str) -> list[str]:
437
+ """A complete suite needs page objects AND runnable tests/steps."""
438
+ names = [Path(f.get("filename", "")).name for f in files]
439
+ missing = []
440
+ if not any(n.endswith("_page.py") or "page" in n for n in names):
441
+ missing.append("page object file (pages/<name>_page.py)")
442
+ if mode == "bdd":
443
+ if not any(n.endswith(".feature") for n in names):
444
+ missing.append("feature file (features/<name>.feature)")
445
+ if not any(n.startswith("test_") for n in names):
446
+ missing.append("step definitions (step_definitions/test_<name>_steps.py)")
447
+ else:
448
+ if not any(n.startswith("test_") and n.endswith(".py") for n in names):
449
+ missing.append("test file (tests/test_<name>.py)")
450
+ return missing
451
+
452
+ @staticmethod
453
+ def _sanitize(files: list[dict]) -> list[dict]:
454
+ """Strip By imports the LLM sneaked into test files (locators live in page objects)."""
455
+ cleaned = []
456
+ for f in files:
457
+ filename = f.get("filename", "")
458
+ content = f.get("content", "")
459
+ name = Path(filename).name
460
+ if name.startswith("test_") and name.endswith(".py"):
461
+ new_content, n = re.subn(
462
+ r"^from selenium\.webdriver\.common\.by import By\s*\n",
463
+ "", content, flags=re.MULTILINE,
464
+ )
465
+ if n:
466
+ logger.warning(f"⚠️ Removed By import from {filename} (belongs in page object)")
467
+ content = new_content
468
+ cleaned.append({"filename": filename, "content": content})
469
+ return cleaned
470
+
471
+ @staticmethod
472
+ def _force_correct_url(code: str, correct_url: str) -> str:
473
+ from urllib.parse import urlparse
474
+ placeholders = [
475
+ r'https?://(?:www\.)?example\.com[^\s\'"]*',
476
+ r'https?://(?:www\.)?placeholder\.com[^\s\'"]*',
477
+ r'https?://(?:www\.)?yoursite\.com[^\s\'"]*',
478
+ r'https?://(?:www\.)?testsite\.com[^\s\'"]*',
479
+ r'https?://(?:www\.)?myapp\.com[^\s\'"]*',
480
+ ]
481
+ base = re.match(r'(https?://[^/]+)', correct_url)
482
+ base_url = base.group(1) if base else correct_url
483
+ for p in placeholders:
484
+ code = re.sub(p, base_url, code, flags=re.IGNORECASE)
485
+
486
+ def fix_url_const(m):
487
+ q, existing = m.group(1), m.group(2)
488
+ cd = urlparse(correct_url).netloc
489
+ ed = urlparse(existing).netloc if existing.startswith('http') else ''
490
+ return f'URL = {q}{base_url}{q}' if cd and ed and cd not in ed else m.group(0)
491
+ code = re.sub(r'URL\s*=\s*([\'"])([^\'"]+)\1', fix_url_const, code)
492
+ return code
493
+
494
+ def _write_conftest(self, project_profile=None, headless: bool = False,
495
+ markers: list[str] | None = None):
496
+ if project_profile and project_profile.has_conftest:
497
+ logger.info("⏭️ Skipping conftest.py — already exists")
498
+ return
499
+ conftest_path = Path(self.output_dir) / "conftest.py"
500
+ conftest_path.parent.mkdir(parents=True, exist_ok=True)
501
+ content = (CONFTEST_CONTENT
502
+ .replace("__HEADLESS__", str(bool(headless)))
503
+ .replace("__MARKERS__", repr(sorted(set(markers or ["smoke"])))))
504
+ conftest_path.write_text(content, encoding="utf-8")
505
+ logger.info(f"✅ Created: {conftest_path}")
@@ -0,0 +1,152 @@
1
+ """
2
+ AGENT DEFINITIONS
3
+ =================
4
+ `selenium-agent init-agents` — the Selenium equivalent of
5
+ `npx playwright init-agents --loop=claude`.
6
+
7
+ Writes three Claude Code subagent definitions into the target project's
8
+ .claude/agents/ directory so Claude Code can drive the Planner, Generator
9
+ and Healer conversationally:
10
+
11
+ .claude/agents/selenium-test-planner.md
12
+ .claude/agents/selenium-test-generator.md
13
+ .claude/agents/selenium-test-healer.md
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ PLANNER_AGENT = """---
21
+ name: selenium-test-planner
22
+ description: >
23
+ Use this agent to create a comprehensive Selenium Python test plan for a
24
+ web application. It scans the live DOM (real locators, never guessed) and
25
+ saves a reviewable Markdown plan plus a machine-readable JSON plan into
26
+ specs/. Example - "plan tests for the login page of https://myapp.com".
27
+ tools: Bash, Read, Glob, Grep
28
+ model: inherit
29
+ ---
30
+
31
+ You are a Selenium test planning agent. You produce test plans by driving
32
+ the `selenium-agent` CLI — you do not write test code yourself.
33
+
34
+ ## Workflow
35
+
36
+ 1. Determine the target URL and what to test from the user's request.
37
+ 2. Run the planner:
38
+ ```bash
39
+ selenium-agent --plan-only "<instruction>" --url <target-url>
40
+ ```
41
+ This performs a live headless DOM scan and writes:
42
+ - `specs/<slug>.md` ← human-readable plan (show this to the user)
43
+ - `specs/<slug>.json` ← generator input
44
+ 3. Read the generated `specs/<slug>.md` and summarize the scenarios for the
45
+ user. Ask whether they want changes before generation.
46
+ 4. If the user wants changes, edit `specs/<slug>.json` accordingly (keep the
47
+ schema intact) and regenerate the Markdown summary in your reply.
48
+
49
+ ## Rules
50
+
51
+ - Never invent locators — the plan must only contain locators from the DOM
52
+ scan embedded in the plan output.
53
+ - One page object per distinct page. Never mix pages.
54
+ - Every scenario must have an explicit, assertable expected result.
55
+ """
56
+
57
+ GENERATOR_AGENT = """---
58
+ name: selenium-test-generator
59
+ description: >
60
+ Use this agent to generate Selenium Python (pytest / pytest-bdd) tests
61
+ from a saved test plan in specs/, or directly from an instruction.
62
+ Example - "generate the tests for specs/login-page.json".
63
+ tools: Bash, Read, Write, Edit, Glob, Grep
64
+ model: inherit
65
+ ---
66
+
67
+ You are a Selenium test generation agent. You produce Page Object Model
68
+ test code by driving the `selenium-agent` CLI.
69
+
70
+ ## Workflow
71
+
72
+ 1. If a plan exists in `specs/`, generate from it (no re-planning):
73
+ ```bash
74
+ selenium-agent --from-plan specs/<slug>.json
75
+ ```
76
+ Otherwise run the full pipeline:
77
+ ```bash
78
+ selenium-agent "<instruction>" --url <target-url>
79
+ ```
80
+ 2. To fit generated code into an existing Selenium project (its own
81
+ BasePage, folder layout, naming), add `--project /path/to/project`.
82
+ 3. Read the generated files and confirm they follow the architecture:
83
+ - locators ONLY in `pages/*_page.py` as class constants
84
+ - test files reference `page.LOCATOR_NAME` — no `By` imports
85
+ - `fluent_wait` before interactions, `wait_for_url` after navigation
86
+ 4. Report the created file list to the user.
87
+
88
+ ## Rules
89
+
90
+ - Do not hand-edit generated locators — if they look wrong, re-run the
91
+ planner so locators come from a live DOM scan.
92
+ - Never introduce `time.sleep()`.
93
+ """
94
+
95
+ HEALER_AGENT = """---
96
+ name: selenium-test-healer
97
+ description: >
98
+ Use this agent to debug and fix failing Selenium Python tests. It runs
99
+ pytest, re-scans the live DOM on failure, patches locators/waits in the
100
+ page objects, and re-runs until green. Example - "heal
101
+ tests/test_login.py" or "fix the failing checkout test".
102
+ tools: Bash, Read, Write, Edit, Glob, Grep
103
+ model: inherit
104
+ ---
105
+
106
+ You are a Selenium test healing agent. You fix failing tests by driving
107
+ the `selenium-agent` CLI.
108
+
109
+ ## Workflow
110
+
111
+ 1. Heal an entire file:
112
+ ```bash
113
+ selenium-agent --heal-only <path/to/test_file.py>
114
+ ```
115
+ Heal one specific test (all other tests preserved verbatim):
116
+ ```bash
117
+ selenium-agent --heal-only <path/to/test_file.py> --test <test_name_or_-k_expr>
118
+ ```
119
+ Increase attempts for stubborn failures: `--max-retries 5`.
120
+ 2. The healer re-scans the live DOM on every failure, so fixes use real
121
+ selectors. It validates every fix (syntax + architecture) and always
122
+ verifies the final fix with a test run.
123
+ 3. Report the outcome: status, attempts, and what was changed (diff the
124
+ files with git if available).
125
+
126
+ ## Rules
127
+
128
+ - If healing fails after max retries, read the last pytest output and
129
+ explain the root cause to the user rather than blindly retrying.
130
+ - Never move locators into test files as a "quick fix".
131
+ - If the app itself is broken (a real bug, not a test bug), say so —
132
+ do not force the test to pass by weakening assertions.
133
+ """
134
+
135
+ _AGENTS = {
136
+ "selenium-test-planner.md": PLANNER_AGENT,
137
+ "selenium-test-generator.md": GENERATOR_AGENT,
138
+ "selenium-test-healer.md": HEALER_AGENT,
139
+ }
140
+
141
+
142
+ def write_agent_definitions(target_dir: str | Path = ".") -> list[str]:
143
+ """Write Claude Code agent definitions into <target>/.claude/agents/."""
144
+ agents_dir = Path(target_dir) / ".claude" / "agents"
145
+ agents_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ written = []
148
+ for filename, content in _AGENTS.items():
149
+ path = agents_dir / filename
150
+ path.write_text(content, encoding="utf-8")
151
+ written.append(str(path))
152
+ return written