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,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,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
|