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,452 @@
1
+ """
2
+ PROJECT SCANNER
3
+ ===============
4
+ Scans an existing Selenium Python project and detects:
5
+ - Folder structure (pages/, page_objects/, src/, etc.)
6
+ - Base class (BasePage, BaseTest, PageBase, etc.)
7
+ - Test framework (pytest, unittest, pytest-bdd)
8
+ - Naming conventions (test_login.py vs LoginTest.py)
9
+ - Import style (relative vs absolute)
10
+ - Existing conftest.py, fixtures
11
+ - Driver setup pattern
12
+
13
+ This context is passed to Planner + Coder so generated code
14
+ fits INTO the existing project, not alongside it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+ import os
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+
24
+ @dataclass
25
+ class ProjectProfile:
26
+ """
27
+ Complete profile of a detected Selenium Python project.
28
+ Passed to Planner and Coder agents as context.
29
+ """
30
+
31
+ # Root directory
32
+ root: str = ""
33
+
34
+ # Folder structure
35
+ pages_dir: str = "pages" # where Page Objects live
36
+ tests_dir: str = "tests" # where test files live
37
+ steps_dir: str = "step_definitions" # BDD steps dir
38
+ features_dir: str = "features" # BDD features dir
39
+ utils_dir: str = "" # utils/helpers dir
40
+ base_dir: str = "" # base classes dir
41
+
42
+ # Framework
43
+ test_framework: str = "pytest" # pytest | unittest | pytest-bdd
44
+ has_bdd: bool = False
45
+ has_pytest: bool = True
46
+ has_conftest: bool = False
47
+
48
+ # Base classes
49
+ base_page_class: str = "BasePage" # existing base page name
50
+ base_page_import: str = "" # full import path
51
+ base_test_class: str = "" # existing base test name
52
+ base_test_import: str = ""
53
+
54
+ # Driver setup
55
+ driver_setup: str = "fixture" # fixture | setUp | class-level
56
+ driver_scope: str = "function" # function | class | module
57
+ browser: str = "chrome"
58
+ headless: bool = False
59
+
60
+ # Naming conventions
61
+ test_file_prefix: str = "test_" # test_login.py
62
+ test_file_suffix: str = "" # LoginTest.py → suffix=Test
63
+ page_file_suffix: str = "_page" # login_page.py
64
+ test_func_prefix: str = "test_"
65
+
66
+ # Import style
67
+ import_style: str = "absolute" # absolute | relative
68
+ project_package: str = "" # top-level package name
69
+
70
+ # Existing files (for context)
71
+ existing_page_files: list = field(default_factory=list)
72
+ existing_test_files: list = field(default_factory=list)
73
+ existing_conftest_files: list = field(default_factory=list)
74
+
75
+ # Raw snippets for LLM context
76
+ sample_page_code: str = "" # snippet from existing page object
77
+ sample_test_code: str = "" # snippet from existing test
78
+ sample_conftest_code: str = "" # snippet from conftest
79
+
80
+ def to_llm_context(self) -> str:
81
+ """
82
+ Format the project profile as a clear context string
83
+ for injection into Planner and Coder prompts.
84
+ """
85
+ lines = [
86
+ "=== EXISTING PROJECT PROFILE ===",
87
+ f"Root : {self.root}",
88
+ f"Pages folder : {self.pages_dir}/",
89
+ f"Tests folder : {self.tests_dir}/",
90
+ f"Test framework : {self.test_framework}",
91
+ f"BDD detected : {self.has_bdd}",
92
+ f"Base Page class : {self.base_page_class}",
93
+ f"Base Page import : {self.base_page_import or 'not detected'}",
94
+ f"Driver setup : {self.driver_setup} (scope={self.driver_scope})",
95
+ f"Browser : {self.browser} (headless={self.headless})",
96
+ f"Test file pattern : {self.test_file_prefix}<name>.py",
97
+ f"Page file pattern : <name>{self.page_file_suffix}.py",
98
+ f"Import style : {self.import_style}",
99
+ f"Package : {self.project_package or 'none'}",
100
+ ]
101
+
102
+ if self.existing_page_files:
103
+ lines.append(f"\nExisting pages : {', '.join(self.existing_page_files[:5])}")
104
+ if self.existing_test_files:
105
+ lines.append(f"Existing tests : {', '.join(self.existing_test_files[:5])}")
106
+
107
+ if self.sample_page_code:
108
+ lines.append(f"\n--- SAMPLE PAGE OBJECT (follow this style) ---\n{self.sample_page_code}\n---")
109
+ if self.sample_test_code:
110
+ lines.append(f"\n--- SAMPLE TEST FILE (follow this style) ---\n{self.sample_test_code}\n---")
111
+ if self.sample_conftest_code:
112
+ lines.append(f"\n--- CONFTEST (follow this fixture pattern) ---\n{self.sample_conftest_code}\n---")
113
+
114
+ lines.append("\nINSTRUCTION: Generated code MUST follow this exact project structure,")
115
+ lines.append("import style, base class, and naming convention shown above.")
116
+ lines.append("=================================")
117
+
118
+ return "\n".join(lines)
119
+
120
+
121
+ class ProjectScanner:
122
+ """
123
+ Scans an existing Selenium Python project and returns a ProjectProfile.
124
+
125
+ Usage:
126
+ scanner = ProjectScanner("/path/to/existing/project")
127
+ profile = scanner.scan()
128
+ print(profile.to_llm_context())
129
+ """
130
+
131
+ # Known folder name patterns
132
+ PAGES_DIRS = ["pages", "page_objects", "pageobjects", "page_object",
133
+ "pom", "page_models", "screens", "views", "ui"]
134
+ TESTS_DIRS = ["tests", "test", "test_suite", "testing", "functional",
135
+ "e2e", "integration", "specs", "test_cases"]
136
+ FEATURES_DIRS = ["features", "feature", "bdd", "scenarios"]
137
+ STEPS_DIRS = ["step_definitions", "steps", "step_defs", "stepdefs"]
138
+ UTILS_DIRS = ["utils", "utilities", "helpers", "common", "lib"]
139
+ BASE_DIRS = ["base", "core", "foundation", "abstract"]
140
+
141
+ # Known base class name patterns
142
+ BASE_PAGE_NAMES = ["BasePage", "BasePageObject", "PageBase", "BaseUI",
143
+ "SeleniumBase", "WebPage", "AbstractPage", "PageObject",
144
+ "BaseDriver", "DriverBase"]
145
+
146
+ def __init__(self, project_root: str):
147
+ self.root = Path(project_root).resolve()
148
+ self.profile = ProjectProfile(root=str(self.root))
149
+
150
+ def scan(self) -> ProjectProfile:
151
+ """
152
+ Full project scan. Returns populated ProjectProfile.
153
+ Safe to call on any Python project — read-only, no writes.
154
+ """
155
+ if not self.root.exists():
156
+ raise ValueError(f"Project root does not exist: {self.root}")
157
+
158
+ self._detect_folders()
159
+ self._detect_framework()
160
+ self._detect_base_classes()
161
+ self._detect_driver_setup()
162
+ self._detect_naming_conventions()
163
+ self._detect_import_style()
164
+ self._collect_existing_files()
165
+ self._extract_code_samples()
166
+
167
+ return self.profile
168
+
169
+ # ── Detection methods ─────────────────────────────────────────────
170
+
171
+ def _detect_folders(self):
172
+ """Find pages, tests, features, steps directories."""
173
+ all_dirs = [
174
+ d for d in self.root.rglob("*")
175
+ if d.is_dir() and not self._is_ignored(d)
176
+ ]
177
+ dir_names = {d.name.lower(): d for d in all_dirs}
178
+
179
+ for name in self.PAGES_DIRS:
180
+ if name in dir_names:
181
+ self.profile.pages_dir = str(
182
+ dir_names[name].relative_to(self.root)
183
+ )
184
+ break
185
+
186
+ for name in self.TESTS_DIRS:
187
+ if name in dir_names:
188
+ self.profile.tests_dir = str(
189
+ dir_names[name].relative_to(self.root)
190
+ )
191
+ break
192
+
193
+ for name in self.FEATURES_DIRS:
194
+ if name in dir_names:
195
+ self.profile.features_dir = str(
196
+ dir_names[name].relative_to(self.root)
197
+ )
198
+ break
199
+
200
+ for name in self.STEPS_DIRS:
201
+ if name in dir_names:
202
+ self.profile.steps_dir = str(
203
+ dir_names[name].relative_to(self.root)
204
+ )
205
+ break
206
+
207
+ for name in self.UTILS_DIRS:
208
+ if name in dir_names:
209
+ self.profile.utils_dir = str(
210
+ dir_names[name].relative_to(self.root)
211
+ )
212
+ break
213
+
214
+ for name in self.BASE_DIRS:
215
+ if name in dir_names:
216
+ self.profile.base_dir = str(
217
+ dir_names[name].relative_to(self.root)
218
+ )
219
+ break
220
+
221
+ def _detect_framework(self):
222
+ """Detect test framework from imports and file contents."""
223
+ py_files = list(self.root.rglob("*.py"))[:50] # limit for speed
224
+
225
+ has_pytest_bdd = False
226
+ has_pytest = False
227
+ has_unittest = False
228
+ has_conftest = False
229
+
230
+ for f in py_files:
231
+ if self._is_ignored(f):
232
+ continue
233
+ try:
234
+ content = f.read_text(encoding="utf-8", errors="ignore")
235
+ if "pytest_bdd" in content or "from pytest_bdd" in content:
236
+ has_pytest_bdd = True
237
+ if "import pytest" in content or "from pytest" in content:
238
+ has_pytest = True
239
+ if "unittest" in content:
240
+ has_unittest = True
241
+ if f.name == "conftest.py":
242
+ has_conftest = True
243
+ except Exception:
244
+ continue
245
+
246
+ self.profile.has_bdd = has_pytest_bdd
247
+ self.profile.has_pytest = has_pytest
248
+ self.profile.has_conftest = has_conftest
249
+
250
+ if has_pytest_bdd:
251
+ self.profile.test_framework = "pytest-bdd"
252
+ elif has_pytest:
253
+ self.profile.test_framework = "pytest"
254
+ elif has_unittest:
255
+ self.profile.test_framework = "unittest"
256
+
257
+ def _detect_base_classes(self):
258
+ """Find existing BasePage / BaseTest class definitions."""
259
+ py_files = list(self.root.rglob("*.py"))[:100]
260
+
261
+ for f in py_files:
262
+ if self._is_ignored(f):
263
+ continue
264
+ try:
265
+ content = f.read_text(encoding="utf-8", errors="ignore")
266
+ except Exception:
267
+ continue
268
+
269
+ # Look for class definitions matching known base names
270
+ class_matches = re.findall(r"class\s+(\w+)\s*[\(:]", content)
271
+ for cls_name in class_matches:
272
+ if any(base.lower() in cls_name.lower() for base in
273
+ ["basepage", "pagebase", "pageobject", "baseui",
274
+ "seleniumbase", "webpage", "abstractpage", "basedriver"]):
275
+ self.profile.base_page_class = cls_name
276
+ rel = f.relative_to(self.root)
277
+ # Build import path
278
+ parts = list(rel.with_suffix("").parts)
279
+ self.profile.base_page_import = ".".join(parts)
280
+ break
281
+
282
+ if any(base.lower() in cls_name.lower() for base in
283
+ ["basetest", "testbase", "seleniumtest", "webtest"]):
284
+ self.profile.base_test_class = cls_name
285
+ rel = f.relative_to(self.root)
286
+ parts = list(rel.with_suffix("").parts)
287
+ self.profile.base_test_import = ".".join(parts)
288
+ break
289
+
290
+ def _detect_driver_setup(self):
291
+ """Detect how the driver is initialized (fixture/setUp/class)."""
292
+ conftest_files = list(self.root.rglob("conftest.py"))
293
+ test_files = list(self.root.rglob("test_*.py"))[:20]
294
+
295
+ for f in conftest_files + test_files:
296
+ if self._is_ignored(f):
297
+ continue
298
+ try:
299
+ content = f.read_text(encoding="utf-8", errors="ignore")
300
+ except Exception:
301
+ continue
302
+
303
+ # Fixture pattern
304
+ if "@pytest.fixture" in content and "driver" in content:
305
+ self.profile.driver_setup = "fixture"
306
+ scope_match = re.search(r'@pytest\.fixture\s*\(\s*scope=["\'](\w+)["\']', content)
307
+ if scope_match:
308
+ self.profile.driver_scope = scope_match.group(1)
309
+
310
+ # unittest setUp pattern
311
+ elif "def setUp(self)" in content:
312
+ self.profile.driver_setup = "setUp"
313
+
314
+ # Browser detection
315
+ for browser in ["chrome", "firefox", "edge", "safari"]:
316
+ if browser in content.lower():
317
+ self.profile.browser = browser
318
+ break
319
+
320
+ # Headless detection
321
+ if "headless" in content.lower():
322
+ self.profile.headless = True
323
+
324
+ def _detect_naming_conventions(self):
325
+ """Detect test file and page file naming patterns."""
326
+ py_files = list(self.root.rglob("*.py"))
327
+
328
+ test_prefixed = 0 # test_login.py
329
+ test_suffixed = 0 # LoginTest.py
330
+ page_suffixed = 0 # login_page.py
331
+ page_prefixed = 0 # page_login.py
332
+
333
+ for f in py_files:
334
+ if self._is_ignored(f):
335
+ continue
336
+ name = f.stem # filename without extension
337
+ if name.startswith("test_"):
338
+ test_prefixed += 1
339
+ elif name.endswith("Test") or name.endswith("Tests"):
340
+ test_suffixed += 1
341
+ if name.endswith("_page") or name.endswith("_Page"):
342
+ page_suffixed += 1
343
+ elif name.endswith("Page") or name.endswith("PageObject"):
344
+ page_prefixed += 1
345
+
346
+ # Set based on majority
347
+ if test_suffixed > test_prefixed:
348
+ self.profile.test_file_prefix = ""
349
+ self.profile.test_file_suffix = "Test"
350
+ else:
351
+ self.profile.test_file_prefix = "test_"
352
+ self.profile.test_file_suffix = ""
353
+
354
+ if page_prefixed > page_suffixed:
355
+ self.profile.page_file_suffix = "Page"
356
+ else:
357
+ self.profile.page_file_suffix = "_page"
358
+
359
+ def _detect_import_style(self):
360
+ """Detect relative vs absolute imports."""
361
+ py_files = list(self.root.rglob("*.py"))[:30]
362
+ relative = 0
363
+ absolute = 0
364
+
365
+ for f in py_files:
366
+ if self._is_ignored(f):
367
+ continue
368
+ try:
369
+ content = f.read_text(encoding="utf-8", errors="ignore")
370
+ except Exception:
371
+ continue
372
+
373
+ if re.search(r"^from \.", content, re.MULTILINE):
374
+ relative += 1
375
+ if re.search(r"^from [a-zA-Z]", content, re.MULTILINE):
376
+ absolute += 1
377
+
378
+ self.profile.import_style = "relative" if relative > absolute else "absolute"
379
+
380
+ # Try to detect top-level package
381
+ init_files = [
382
+ f for f in self.root.glob("*/__init__.py") if not self._is_ignored(f)
383
+ ]
384
+ if init_files:
385
+ self.profile.project_package = init_files[0].parent.name
386
+
387
+ def _collect_existing_files(self):
388
+ """Collect lists of existing page and test files."""
389
+ pages_path = self.root / self.profile.pages_dir
390
+ tests_path = self.root / self.profile.tests_dir
391
+
392
+ if pages_path.exists():
393
+ self.profile.existing_page_files = [
394
+ f.name for f in pages_path.rglob("*.py")
395
+ if not f.name.startswith("__")
396
+ ][:10]
397
+
398
+ if tests_path.exists():
399
+ self.profile.existing_test_files = [
400
+ f.name for f in tests_path.rglob("*.py")
401
+ if not f.name.startswith("__")
402
+ ][:10]
403
+
404
+ self.profile.existing_conftest_files = [
405
+ str(f.relative_to(self.root))
406
+ for f in self.root.rglob("conftest.py")
407
+ ][:5]
408
+
409
+ def _extract_code_samples(self):
410
+ """Extract small code snippets as style examples for LLM."""
411
+ pages_path = self.root / self.profile.pages_dir
412
+ tests_path = self.root / self.profile.tests_dir
413
+
414
+ # Sample page object (first 40 lines)
415
+ if pages_path.exists():
416
+ for f in pages_path.rglob("*.py"):
417
+ if f.name.startswith("__"):
418
+ continue
419
+ try:
420
+ lines = f.read_text(encoding="utf-8", errors="ignore").splitlines()
421
+ self.profile.sample_page_code = "\n".join(lines[:40])
422
+ break
423
+ except Exception:
424
+ continue
425
+
426
+ # Sample test file (first 40 lines)
427
+ if tests_path.exists():
428
+ for f in tests_path.rglob("*.py"):
429
+ if f.name.startswith("__"):
430
+ continue
431
+ try:
432
+ lines = f.read_text(encoding="utf-8", errors="ignore").splitlines()
433
+ self.profile.sample_test_code = "\n".join(lines[:40])
434
+ break
435
+ except Exception:
436
+ continue
437
+
438
+ # Conftest sample
439
+ for conftest_path in self.root.rglob("conftest.py"):
440
+ try:
441
+ lines = conftest_path.read_text(encoding="utf-8", errors="ignore").splitlines()
442
+ self.profile.sample_conftest_code = "\n".join(lines[:30])
443
+ break
444
+ except Exception:
445
+ continue
446
+
447
+ def _is_ignored(self, path: Path) -> bool:
448
+ """Skip venv, cache, git, node_modules etc."""
449
+ ignored = {".venv", "venv", "__pycache__", ".git", ".idea",
450
+ "node_modules", ".pytest_cache", "dist", "build",
451
+ ".eggs", "*.egg-info", ".tox", ".mypy_cache"}
452
+ return any(part in ignored for part in path.parts)
@@ -0,0 +1,6 @@
1
+ from selenium_agent.selenium.driver_factory import DriverFactory
2
+ from selenium_agent.selenium.base_page import BasePage
3
+ from selenium_agent.selenium.locator_advisor import LocatorAdvisor
4
+ from selenium_agent.selenium.error_map import SeleniumErrorMap
5
+
6
+ __all__ = ["DriverFactory", "BasePage", "LocatorAdvisor", "SeleniumErrorMap"]