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
selenium_agent/cli.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""CLI Entry Point for Selenium AI Agent"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
# Auto-load .env file if present ā so you never need to pass --api-key manually
|
|
8
|
+
try:
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
load_dotenv()
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
from selenium_agent.core.orchestrator import Orchestrator
|
|
15
|
+
from selenium_agent.utils.logger import setup_logger
|
|
16
|
+
from selenium_agent.utils import config_manager
|
|
17
|
+
|
|
18
|
+
logger = setup_logger("CLI")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _handle_config(argv):
|
|
22
|
+
"""Handle `selenium-agent config [flags]` before main argparse."""
|
|
23
|
+
p = argparse.ArgumentParser(prog="selenium-agent config")
|
|
24
|
+
p.add_argument("--provider", choices=["anthropic", "openai"])
|
|
25
|
+
p.add_argument("--model", help="e.g. gpt-4o-mini, claude-sonnet-4-20250514")
|
|
26
|
+
p.add_argument("--base-url", dest="base_url", help="Default base URL for all tests")
|
|
27
|
+
p.add_argument("--headless", dest="headless", action="store_true", default=None)
|
|
28
|
+
p.add_argument("--no-headless", dest="headless", action="store_false", default=None)
|
|
29
|
+
p.add_argument("--mode", choices=["pytest", "bdd"])
|
|
30
|
+
p.add_argument("--show", action="store_true", help="Print current config")
|
|
31
|
+
args = p.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
if args.show:
|
|
34
|
+
print("\nš Current config:")
|
|
35
|
+
print(json.dumps(config_manager.load(), indent=2))
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
updates = {k: v for k, v in vars(args).items() if v is not None and k != "show"}
|
|
39
|
+
if not updates:
|
|
40
|
+
p.print_help()
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
path = config_manager.save(updates)
|
|
44
|
+
print(f"\nā
Config saved to {path}")
|
|
45
|
+
print(json.dumps(config_manager.load(), indent=2))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _handle_init_agents(argv):
|
|
49
|
+
"""Handle `selenium-agent init-agents [--project PATH]`."""
|
|
50
|
+
p = argparse.ArgumentParser(prog="selenium-agent init-agents")
|
|
51
|
+
p.add_argument("--project", default=".", metavar="PATH",
|
|
52
|
+
help="Project to install the agent definitions into (default: .)")
|
|
53
|
+
args = p.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
from selenium_agent.agents.definitions import write_agent_definitions
|
|
56
|
+
written = write_agent_definitions(args.project)
|
|
57
|
+
print("\nā
Claude Code agent definitions installed:")
|
|
58
|
+
for path in written:
|
|
59
|
+
print(f" š {path}")
|
|
60
|
+
print("\nOpen Claude Code in this project and ask e.g.:")
|
|
61
|
+
print(' "use selenium-test-planner to plan tests for https://www.saucedemo.com"')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _handle_help():
|
|
65
|
+
"""Print a friendly cheatsheet of all commands."""
|
|
66
|
+
print("""
|
|
67
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
68
|
+
ā š¤ Selenium Python AI Agent ā Command Reference ā
|
|
69
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
70
|
+
|
|
71
|
+
š§ ONE-TIME SETUP
|
|
72
|
+
selenium-agent config --provider openai --model gpt-4o-mini
|
|
73
|
+
selenium-agent config --base-url https://www.yoursite.com
|
|
74
|
+
selenium-agent config --headless (--no-headless to turn off)
|
|
75
|
+
selenium-agent config --show ā verify saved config
|
|
76
|
+
|
|
77
|
+
š GENERATE TESTS (plan + code + heal)
|
|
78
|
+
selenium-agent "test the login page" ā uses saved config & URL
|
|
79
|
+
selenium-agent "test login of saucedemo.com" ā URL auto-detected
|
|
80
|
+
selenium-agent "test login" --url https://myapp.com ā override URL once
|
|
81
|
+
selenium-agent "test login page" --no-heal ā generate only, skip heal
|
|
82
|
+
selenium-agent "test login page" --mode bdd ā BDD/Gherkin output
|
|
83
|
+
selenium-agent "test login page" --headless ā headless browser
|
|
84
|
+
selenium-agent "test login page" --explore 3 ā scan 3 extra pages for locators
|
|
85
|
+
selenium-agent "test login page" --output-dir my_tests/
|
|
86
|
+
selenium-agent "test login page" --max-retries 5
|
|
87
|
+
selenium-agent "test login page" --project /path/to/existing/project
|
|
88
|
+
|
|
89
|
+
š» PLAN ONLY (saved to specs/<slug>.md + .json for review)
|
|
90
|
+
selenium-agent --plan-only "test the login page"
|
|
91
|
+
|
|
92
|
+
š GENERATE FROM A SAVED PLAN (review/edit the plan first)
|
|
93
|
+
selenium-agent --from-plan specs/test-the-login-page.json
|
|
94
|
+
|
|
95
|
+
𩺠HEAL EXISTING TESTS
|
|
96
|
+
selenium-agent --heal-only generated_tests/tests/test_login.py
|
|
97
|
+
selenium-agent --heal-only generated_tests/tests/test_login.py \\
|
|
98
|
+
--test test_login_locked_out_user ā specific test only
|
|
99
|
+
selenium-agent --heal-only generated_tests/tests/test_login.py \\
|
|
100
|
+
--test "locked_out or invalid_password" ā multiple (-k syntax)
|
|
101
|
+
|
|
102
|
+
š SCAN PROJECT
|
|
103
|
+
selenium-agent --scan /path/to/project
|
|
104
|
+
|
|
105
|
+
š¤ CLAUDE CODE INTEGRATION (like `playwright init-agents`)
|
|
106
|
+
selenium-agent init-agents ā installs .claude/agents/*.md
|
|
107
|
+
selenium-agent init-agents --project /path/to/project
|
|
108
|
+
|
|
109
|
+
āļø CONFIG OPTIONS
|
|
110
|
+
--provider anthropic | openai (default: from config)
|
|
111
|
+
--model e.g. gpt-4o-mini (default: from config)
|
|
112
|
+
--api-key your key (prefer .env instead)
|
|
113
|
+
--url override base URL once (auto-saved to config)
|
|
114
|
+
--mode pytest | bdd (default: pytest)
|
|
115
|
+
--headless headless browser
|
|
116
|
+
--explore N scan N extra same-origin pages while planning
|
|
117
|
+
--output-dir where to save files (default: generated_tests)
|
|
118
|
+
--project path to existing project
|
|
119
|
+
--max-retries healer retries (default: 3)
|
|
120
|
+
--no-heal skip healing
|
|
121
|
+
--plan-only preview plan only (saved to specs/)
|
|
122
|
+
--from-plan generate from saved plan JSON
|
|
123
|
+
--test target specific test (use with --heal-only)
|
|
124
|
+
--version show version
|
|
125
|
+
|
|
126
|
+
š GENERATED STRUCTURE
|
|
127
|
+
specs/<slug>.md ā reviewable test plan (planner output)
|
|
128
|
+
specs/<slug>.json ā machine-readable plan (generator input)
|
|
129
|
+
generated_tests/
|
|
130
|
+
āāā pages/login_page.py ā Page Object (locators live here)
|
|
131
|
+
āāā tests/test_login.py ā pytest tests (no raw locators)
|
|
132
|
+
āāā conftest.py ā driver fixture
|
|
133
|
+
""")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
# āā subcommands handled before argparse to avoid positional conflicts āā
|
|
138
|
+
if len(sys.argv) > 1 and sys.argv[1] == "config":
|
|
139
|
+
_handle_config(sys.argv[2:])
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
if len(sys.argv) > 1 and sys.argv[1] == "init-agents":
|
|
143
|
+
_handle_init_agents(sys.argv[2:])
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if len(sys.argv) > 1 and sys.argv[1] == "help":
|
|
147
|
+
_handle_help()
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
prog="selenium-agent",
|
|
152
|
+
description="š¤ Selenium Python AI Agent ā Plan, Code & Heal tests automatically",
|
|
153
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
154
|
+
epilog="""
|
|
155
|
+
One-time setup:
|
|
156
|
+
selenium-agent config --provider openai --model gpt-4o-mini
|
|
157
|
+
selenium-agent config --base-url https://saucedemo.com
|
|
158
|
+
selenium-agent config --show
|
|
159
|
+
|
|
160
|
+
After setup, just run:
|
|
161
|
+
selenium-agent "test the login page"
|
|
162
|
+
selenium-agent "test the checkout flow" ā base_url reused automatically
|
|
163
|
+
|
|
164
|
+
Other examples:
|
|
165
|
+
selenium-agent "test login" --url https://myapp.com ā override URL once
|
|
166
|
+
selenium-agent "test login page" --mode bdd
|
|
167
|
+
selenium-agent --plan-only "test login page"
|
|
168
|
+
selenium-agent --from-plan specs/test-login-page.json
|
|
169
|
+
selenium-agent --heal-only generated_tests/tests/test_login.py
|
|
170
|
+
selenium-agent init-agents ā Claude Code agents (planner/generator/healer)
|
|
171
|
+
"""
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
parser.add_argument("instruction", nargs="?",
|
|
175
|
+
help="What to test. E.g: 'test the login page'")
|
|
176
|
+
parser.add_argument("--api-key", default=None,
|
|
177
|
+
help="LLM API key (or set in .env)")
|
|
178
|
+
parser.add_argument("--provider", default=None, choices=["anthropic", "openai"],
|
|
179
|
+
help="LLM provider (default from config)")
|
|
180
|
+
parser.add_argument("--model", default=None, help="Override model")
|
|
181
|
+
parser.add_argument("--mode", default=None, choices=["pytest", "bdd"])
|
|
182
|
+
parser.add_argument("--headless", action="store_true", default=False)
|
|
183
|
+
parser.add_argument("--url", default=None,
|
|
184
|
+
help="Override base URL for this run (also saves to config)")
|
|
185
|
+
parser.add_argument("--project", default=None, metavar="PATH")
|
|
186
|
+
parser.add_argument("--output-dir", default="generated_tests")
|
|
187
|
+
parser.add_argument("--max-retries", type=int, default=None)
|
|
188
|
+
parser.add_argument("--explore", type=int, default=0, metavar="N",
|
|
189
|
+
help="Scan up to N extra same-origin pages while planning")
|
|
190
|
+
parser.add_argument("--no-heal", action="store_true")
|
|
191
|
+
parser.add_argument("--plan-only", action="store_true")
|
|
192
|
+
parser.add_argument("--from-plan", default=None, metavar="FILE",
|
|
193
|
+
help="Generate code from a saved specs/<slug>.json plan")
|
|
194
|
+
parser.add_argument("--scan", default=None, metavar="PATH")
|
|
195
|
+
parser.add_argument("--heal-only", nargs="+", metavar="FILE")
|
|
196
|
+
parser.add_argument("--test", default=None, metavar="TEST_NAME",
|
|
197
|
+
help="Specific test function to run/heal. "
|
|
198
|
+
"e.g. --test test_login_locked_out_user")
|
|
199
|
+
parser.add_argument("--version", action="version", version="selenium-agent 0.2.0")
|
|
200
|
+
|
|
201
|
+
args = parser.parse_args()
|
|
202
|
+
|
|
203
|
+
# āā Scan only āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
204
|
+
if args.scan:
|
|
205
|
+
from selenium_agent.scanner.project_scanner import ProjectScanner
|
|
206
|
+
print(f"\nš Scanning: {args.scan}\n")
|
|
207
|
+
try:
|
|
208
|
+
profile = ProjectScanner(args.scan).scan()
|
|
209
|
+
print(profile.to_llm_context())
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"ā Scan failed: {e}")
|
|
212
|
+
sys.exit(1)
|
|
213
|
+
sys.exit(0)
|
|
214
|
+
|
|
215
|
+
# āā Merge config + CLI args āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
216
|
+
cfg = config_manager.get_effective({
|
|
217
|
+
"provider": args.provider,
|
|
218
|
+
"model": args.model,
|
|
219
|
+
"headless": args.headless,
|
|
220
|
+
"mode": args.mode,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
# URL priority: --url flag > extracted from instruction > saved base_url
|
|
224
|
+
from selenium_agent.utils.url_extractor import extract_url
|
|
225
|
+
override_url = args.url
|
|
226
|
+
if not override_url and args.instruction:
|
|
227
|
+
override_url = extract_url(args.instruction)
|
|
228
|
+
if not override_url:
|
|
229
|
+
override_url = cfg.get("base_url")
|
|
230
|
+
if override_url:
|
|
231
|
+
logger.info(f"š Using saved base_url: {override_url}")
|
|
232
|
+
|
|
233
|
+
# If new URL given via --url, persist it for future runs
|
|
234
|
+
if args.url and args.url != cfg.get("base_url"):
|
|
235
|
+
config_manager.save({"base_url": args.url})
|
|
236
|
+
logger.info(f"š¾ base_url saved: {args.url}")
|
|
237
|
+
|
|
238
|
+
# āā Build orchestrator āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
239
|
+
try:
|
|
240
|
+
agent = Orchestrator(
|
|
241
|
+
api_key=args.api_key,
|
|
242
|
+
provider=cfg["provider"],
|
|
243
|
+
model=cfg.get("model"),
|
|
244
|
+
output_dir=args.output_dir,
|
|
245
|
+
max_heal_retries=args.max_retries or 5,
|
|
246
|
+
auto_heal=not args.no_heal,
|
|
247
|
+
mode=cfg["mode"],
|
|
248
|
+
project_root=args.project,
|
|
249
|
+
headless=cfg["headless"],
|
|
250
|
+
explore_pages=args.explore,
|
|
251
|
+
)
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
print(f"\nā {exc}\n")
|
|
254
|
+
sys.exit(1)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
if args.heal_only:
|
|
258
|
+
result = agent.heal_only(args.heal_only, test_filter=args.test)
|
|
259
|
+
emoji = "ā
" if result["status"] == "passed" else "ā"
|
|
260
|
+
print(f"\n{emoji} {result['status']} (attempts: {result['attempts']})")
|
|
261
|
+
sys.exit(0 if result["status"] == "passed" else 1)
|
|
262
|
+
|
|
263
|
+
if args.from_plan:
|
|
264
|
+
result = agent.run_from_plan(args.from_plan)
|
|
265
|
+
_print_run_result(result)
|
|
266
|
+
sys.exit(0)
|
|
267
|
+
|
|
268
|
+
if not args.instruction:
|
|
269
|
+
parser.print_help()
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
if args.plan_only:
|
|
273
|
+
plan = agent.plan_only(args.instruction, override_url=override_url)
|
|
274
|
+
spec_files = plan.pop("_spec_files", {})
|
|
275
|
+
print("\nš TEST PLAN:")
|
|
276
|
+
print(json.dumps(plan, indent=2))
|
|
277
|
+
if spec_files:
|
|
278
|
+
print(f"\nš Saved: {spec_files.get('markdown')}")
|
|
279
|
+
print(f"š Saved: {spec_files.get('json')}")
|
|
280
|
+
print(f"\nReview/edit the plan, then generate with:")
|
|
281
|
+
print(f" selenium-agent --from-plan {spec_files.get('json')}")
|
|
282
|
+
sys.exit(0)
|
|
283
|
+
|
|
284
|
+
result = agent.run(args.instruction, override_url=override_url)
|
|
285
|
+
_print_run_result(result)
|
|
286
|
+
sys.exit(0)
|
|
287
|
+
|
|
288
|
+
except KeyboardInterrupt:
|
|
289
|
+
print("\n\nā ļø Interrupted")
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Fatal: {e}")
|
|
293
|
+
print(f"\nā Error: {e}")
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _print_run_result(result: dict):
|
|
298
|
+
print(f"\nā
Generated {len(result['files'])} files:")
|
|
299
|
+
for f in result["files"]:
|
|
300
|
+
print(f" š {f}")
|
|
301
|
+
|
|
302
|
+
spec = result.get("spec") or {}
|
|
303
|
+
if spec.get("markdown"):
|
|
304
|
+
print(f"\nš Test plan: {spec['markdown']}")
|
|
305
|
+
|
|
306
|
+
if result.get("heal_result"):
|
|
307
|
+
status = result["heal_result"]["status"]
|
|
308
|
+
attempts = result["heal_result"]["attempts"]
|
|
309
|
+
emoji = "ā
" if status == "passed" else "ā"
|
|
310
|
+
print(f"\n{emoji} Tests {status} after {attempts} attempt(s)")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
main()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""ORCHESTRATOR ā Coordinates Planner ā Coder (Generator) ā Healer
|
|
2
|
+
|
|
3
|
+
Mirrors the Playwright agents workflow:
|
|
4
|
+
plan ā specs/<slug>.md + specs/<slug>.json (reviewable artifacts)
|
|
5
|
+
generate ā Page Object Model code from a plan (fresh or saved)
|
|
6
|
+
heal ā run, live-DOM re-scan, fix, verify ā until green
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from selenium_agent.agents.planner import PlannerAgent
|
|
12
|
+
from selenium_agent.agents.coder import CoderAgent
|
|
13
|
+
from selenium_agent.agents.healer import HealerAgent
|
|
14
|
+
from selenium_agent.utils.logger import setup_logger
|
|
15
|
+
from selenium_agent.utils.url_extractor import extract_url
|
|
16
|
+
from selenium_agent.utils.spec_writer import save_spec, load_plan
|
|
17
|
+
from selenium_agent.utils.llm import (
|
|
18
|
+
DEFAULT_PROVIDER, format_missing_api_key_error,
|
|
19
|
+
get_default_model, normalize_provider, resolve_api_key,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
logger = setup_logger("Orchestrator")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Orchestrator:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str = None,
|
|
29
|
+
output_dir: str = "generated_tests",
|
|
30
|
+
max_heal_retries: int = 5,
|
|
31
|
+
auto_heal: bool = True,
|
|
32
|
+
provider: str = DEFAULT_PROVIDER,
|
|
33
|
+
model: str | None = None,
|
|
34
|
+
mode: str = "pytest",
|
|
35
|
+
project_root: str | None = None,
|
|
36
|
+
headless: bool = False,
|
|
37
|
+
explore_pages: int = 0,
|
|
38
|
+
):
|
|
39
|
+
self.provider = normalize_provider(provider)
|
|
40
|
+
self.model = model or get_default_model(self.provider)
|
|
41
|
+
self.api_key = resolve_api_key(provider=self.provider, api_key=api_key)
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
raise ValueError(format_missing_api_key_error(self.provider))
|
|
44
|
+
|
|
45
|
+
self.output_dir = output_dir
|
|
46
|
+
self.auto_heal = auto_heal
|
|
47
|
+
self.mode = mode if mode in ("pytest", "bdd") else "pytest"
|
|
48
|
+
self.headless = headless
|
|
49
|
+
self.project_root = project_root
|
|
50
|
+
self.explore_pages = explore_pages
|
|
51
|
+
|
|
52
|
+
self.project_profile = None
|
|
53
|
+
if project_root:
|
|
54
|
+
self._scan_project(project_root)
|
|
55
|
+
|
|
56
|
+
self.planner = PlannerAgent(api_key=self.api_key, provider=self.provider, model=self.model)
|
|
57
|
+
self.coder = CoderAgent(api_key=self.api_key, output_dir=self.output_dir,
|
|
58
|
+
provider=self.provider, model=self.model)
|
|
59
|
+
self.healer = HealerAgent(api_key=self.api_key, output_dir=self.output_dir,
|
|
60
|
+
max_retries=max_heal_retries,
|
|
61
|
+
provider=self.provider, model=self.model)
|
|
62
|
+
|
|
63
|
+
def _scan_project(self, project_root: str):
|
|
64
|
+
from selenium_agent.scanner.project_scanner import ProjectScanner
|
|
65
|
+
logger.info(f"š Scanning: {project_root}")
|
|
66
|
+
try:
|
|
67
|
+
self.project_profile = ProjectScanner(project_root).scan()
|
|
68
|
+
logger.info(
|
|
69
|
+
f"ā
Detected: {self.project_profile.test_framework} | "
|
|
70
|
+
f"pages={self.project_profile.pages_dir} | "
|
|
71
|
+
f"base={self.project_profile.base_page_class}"
|
|
72
|
+
)
|
|
73
|
+
if self.output_dir == "generated_tests":
|
|
74
|
+
self.output_dir = project_root
|
|
75
|
+
self.coder.output_dir = project_root
|
|
76
|
+
self.healer.output_dir = str(Path(project_root).resolve())
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"ā ļø Scan failed: {e} ā using defaults")
|
|
79
|
+
|
|
80
|
+
# āā Spec persistence (Playwright-planner style) āāāāāāāāāāāāāāāāāāāāāā
|
|
81
|
+
|
|
82
|
+
def _specs_dir(self) -> Path:
|
|
83
|
+
root = Path(self.project_root) if self.project_root else Path.cwd()
|
|
84
|
+
return root / "specs"
|
|
85
|
+
|
|
86
|
+
def _save_spec(self, plan: dict, instruction: str) -> dict:
|
|
87
|
+
try:
|
|
88
|
+
paths = save_spec(plan, instruction, specs_dir=self._specs_dir())
|
|
89
|
+
logger.info(f"š Plan saved: {paths['markdown']} (+ .json twin)")
|
|
90
|
+
return paths
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"ā ļø Could not save spec files: {e}")
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
# āā Pipelines āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
96
|
+
|
|
97
|
+
def run(self, instruction: str, override_url: str | None = None) -> dict:
|
|
98
|
+
logger.info("=" * 60)
|
|
99
|
+
logger.info(f"š Selenium AI Agent | mode={self.mode.upper()} | headless={self.headless}")
|
|
100
|
+
logger.info(f"š Task: {instruction}")
|
|
101
|
+
logger.info("=" * 60)
|
|
102
|
+
|
|
103
|
+
# URL priority: --url flag > extracted from instruction > LLM decides
|
|
104
|
+
target_url = override_url or extract_url(instruction)
|
|
105
|
+
if override_url:
|
|
106
|
+
logger.info(f"š URL override (--url flag): {target_url}")
|
|
107
|
+
elif target_url:
|
|
108
|
+
logger.info(f"š Target URL detected: {target_url}")
|
|
109
|
+
else:
|
|
110
|
+
logger.warning("ā ļø No URL detected ā LLM will infer from instruction")
|
|
111
|
+
|
|
112
|
+
logger.info("\nš§ STEP 1: PLANNER")
|
|
113
|
+
plan = self._plan(instruction, target_url)
|
|
114
|
+
spec_paths = self._save_spec(plan, instruction)
|
|
115
|
+
|
|
116
|
+
saved_files, heal_result = self._generate_and_heal(plan)
|
|
117
|
+
|
|
118
|
+
logger.info("\n" + "=" * 60)
|
|
119
|
+
logger.info("š DONE!")
|
|
120
|
+
logger.info(f"š Files in: {self.output_dir}/")
|
|
121
|
+
logger.info("=" * 60)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"plan": plan,
|
|
125
|
+
"spec": spec_paths,
|
|
126
|
+
"files": saved_files,
|
|
127
|
+
"heal_result": heal_result,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def run_from_plan(self, plan_file: str) -> dict:
|
|
131
|
+
"""Generate (and heal) directly from a saved specs/<slug>.json plan."""
|
|
132
|
+
plan = load_plan(plan_file)
|
|
133
|
+
if self.headless:
|
|
134
|
+
plan["headless"] = True
|
|
135
|
+
logger.info(f"š Loaded plan: {plan_file} "
|
|
136
|
+
f"({len(plan.get('test_scenarios') or plan.get('scenarios', []))} scenario(s))")
|
|
137
|
+
saved_files, heal_result = self._generate_and_heal(plan)
|
|
138
|
+
return {"plan": plan, "files": saved_files, "heal_result": heal_result}
|
|
139
|
+
|
|
140
|
+
def plan_only(self, instruction: str, override_url: str | None = None) -> dict:
|
|
141
|
+
target_url = override_url or extract_url(instruction)
|
|
142
|
+
plan = self._plan(instruction, target_url)
|
|
143
|
+
spec_paths = self._save_spec(plan, instruction)
|
|
144
|
+
plan["_spec_files"] = spec_paths
|
|
145
|
+
return plan
|
|
146
|
+
|
|
147
|
+
def code_only(self, plan: dict) -> list:
|
|
148
|
+
return self.coder.code(plan, project_profile=self.project_profile)
|
|
149
|
+
|
|
150
|
+
def heal_only(self, file_paths: list, test_filter: str | None = None) -> dict:
|
|
151
|
+
return self.healer.heal(file_paths, test_filter=test_filter)
|
|
152
|
+
|
|
153
|
+
def scan_only(self, project_root: str) -> str:
|
|
154
|
+
from selenium_agent.scanner.project_scanner import ProjectScanner
|
|
155
|
+
return ProjectScanner(project_root).scan().to_llm_context()
|
|
156
|
+
|
|
157
|
+
# āā Internals āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
158
|
+
|
|
159
|
+
def _plan(self, instruction: str, target_url: str | None) -> dict:
|
|
160
|
+
plan = self.planner.plan(
|
|
161
|
+
instruction, mode=self.mode,
|
|
162
|
+
project_profile=self.project_profile,
|
|
163
|
+
headless=self.headless,
|
|
164
|
+
target_url=target_url,
|
|
165
|
+
explore_pages=self.explore_pages,
|
|
166
|
+
)
|
|
167
|
+
# Force URL and headless into plan ā LLM cannot override these
|
|
168
|
+
if target_url:
|
|
169
|
+
plan["url"] = target_url
|
|
170
|
+
plan["headless"] = self.headless
|
|
171
|
+
return plan
|
|
172
|
+
|
|
173
|
+
def _generate_and_heal(self, plan: dict) -> tuple[list, dict | None]:
|
|
174
|
+
logger.info("\nš» STEP 2: GENERATOR")
|
|
175
|
+
try:
|
|
176
|
+
saved_files = self.coder.code(plan, project_profile=self.project_profile)
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
logger.error(f"š„ Generator failed: {e}")
|
|
179
|
+
logger.error("š” Tip: Complex multi-page flows may exceed token limits.")
|
|
180
|
+
logger.error(" Try: simplifying the instruction or splitting the plan.")
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
heal_result = None
|
|
184
|
+
if self.auto_heal:
|
|
185
|
+
logger.info("\n𩺠STEP 3: HEALER")
|
|
186
|
+
heal_result = self.healer.heal(saved_files)
|
|
187
|
+
else:
|
|
188
|
+
logger.info("\nāļø Auto-heal skipped")
|
|
189
|
+
return saved_files, heal_result
|