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,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CONFIG MANAGER
|
|
3
|
+
==============
|
|
4
|
+
Reads/writes .selenium-agent.json in the project root.
|
|
5
|
+
Stores user preferences so they don't have to repeat CLI flags.
|
|
6
|
+
|
|
7
|
+
Priority order (highest to lowest):
|
|
8
|
+
CLI flag > .env > .selenium-agent.json > built-in defaults
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from selenium_agent.utils.logger import setup_logger
|
|
15
|
+
|
|
16
|
+
logger = setup_logger("Config")
|
|
17
|
+
|
|
18
|
+
CONFIG_FILENAME = ".selenium-agent.json"
|
|
19
|
+
|
|
20
|
+
DEFAULTS = {
|
|
21
|
+
"provider": "anthropic",
|
|
22
|
+
"model": None,
|
|
23
|
+
"headless": False,
|
|
24
|
+
"mode": "pytest",
|
|
25
|
+
"base_url": None, # persisted across runs — no need to pass --url repeatedly
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_config_path() -> Path:
|
|
30
|
+
"""Look for config in CWD first, then home directory."""
|
|
31
|
+
cwd_config = Path.cwd() / CONFIG_FILENAME
|
|
32
|
+
if cwd_config.exists():
|
|
33
|
+
return cwd_config
|
|
34
|
+
return Path.home() / CONFIG_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load() -> dict:
|
|
38
|
+
"""Load config from file. Returns defaults if file doesn't exist."""
|
|
39
|
+
path = _find_config_path()
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return dict(DEFAULTS)
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
44
|
+
merged = {**DEFAULTS, **data}
|
|
45
|
+
return merged
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.warning(f"⚠️ Could not read {path}: {e} — using defaults")
|
|
48
|
+
return dict(DEFAULTS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save(updates: dict, config_path: Path | None = None) -> Path:
|
|
52
|
+
"""
|
|
53
|
+
Merge updates into existing config and save.
|
|
54
|
+
Creates config in CWD if it doesn't exist yet.
|
|
55
|
+
"""
|
|
56
|
+
path = config_path or _find_config_path()
|
|
57
|
+
if not path.exists():
|
|
58
|
+
path = Path.cwd() / CONFIG_FILENAME
|
|
59
|
+
|
|
60
|
+
current = load()
|
|
61
|
+
current.update({k: v for k, v in updates.items() if v is not None})
|
|
62
|
+
|
|
63
|
+
path.write_text(json.dumps(current, indent=2), encoding="utf-8")
|
|
64
|
+
return path
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_effective(cli_args: dict) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
Merge config + CLI args. CLI always wins over config.
|
|
70
|
+
cli_args: dict of {key: value} from argparse (None means not provided).
|
|
71
|
+
"""
|
|
72
|
+
config = load()
|
|
73
|
+
effective = dict(config)
|
|
74
|
+
|
|
75
|
+
for key, value in cli_args.items():
|
|
76
|
+
if value is not None and value is not False:
|
|
77
|
+
effective[key] = value
|
|
78
|
+
elif key == "headless" and value is True:
|
|
79
|
+
effective[key] = True
|
|
80
|
+
|
|
81
|
+
return effective
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON UTILITIES
|
|
3
|
+
==============
|
|
4
|
+
Robust extraction of JSON objects from LLM responses.
|
|
5
|
+
|
|
6
|
+
LLMs wrap JSON in markdown fences, add prose before/after, or truncate
|
|
7
|
+
mid-object when they hit the token limit. Every agent (Planner, Coder,
|
|
8
|
+
Healer) parses LLM JSON through this single module so the behavior is
|
|
9
|
+
consistent and battle-tested in one place.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMJSONError(ValueError):
|
|
19
|
+
"""Raised when no usable JSON object could be extracted from an LLM response."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, raw: str = ""):
|
|
22
|
+
snippet = (raw or "")[:400]
|
|
23
|
+
super().__init__(f"{message}\n--- response snippet ---\n{snippet}")
|
|
24
|
+
self.raw = raw
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_markdown_fences(raw: str) -> str:
|
|
28
|
+
"""Remove ```json ... ``` fences, keeping the largest fenced block if present."""
|
|
29
|
+
if "```" not in raw:
|
|
30
|
+
return raw.strip()
|
|
31
|
+
blocks = re.findall(r"```(?:json)?\s*([\s\S]*?)```", raw)
|
|
32
|
+
if blocks:
|
|
33
|
+
# Take the largest block — small ones are usually inline examples
|
|
34
|
+
return max(blocks, key=len).strip()
|
|
35
|
+
# Unclosed fence: drop everything before the first fence marker
|
|
36
|
+
return raw.split("```", 1)[-1].removeprefix("json").strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_balanced_object(raw: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Return the first balanced top-level {...} object in raw.
|
|
42
|
+
If the object is truncated, repair it structurally: close the open
|
|
43
|
+
string, then close arrays/objects in the reverse order they were opened.
|
|
44
|
+
"""
|
|
45
|
+
start = raw.find("{")
|
|
46
|
+
if start == -1:
|
|
47
|
+
raise LLMJSONError("No JSON object found in LLM response", raw)
|
|
48
|
+
|
|
49
|
+
stack: list[str] = []
|
|
50
|
+
in_str, esc = False, False
|
|
51
|
+
for i, ch in enumerate(raw[start:], start):
|
|
52
|
+
if esc:
|
|
53
|
+
esc = False
|
|
54
|
+
continue
|
|
55
|
+
if ch == "\\" and in_str:
|
|
56
|
+
esc = True
|
|
57
|
+
continue
|
|
58
|
+
if ch == '"':
|
|
59
|
+
in_str = not in_str
|
|
60
|
+
continue
|
|
61
|
+
if in_str:
|
|
62
|
+
continue
|
|
63
|
+
if ch in "{[":
|
|
64
|
+
stack.append(ch)
|
|
65
|
+
elif ch in "}]":
|
|
66
|
+
if stack:
|
|
67
|
+
stack.pop()
|
|
68
|
+
if not stack:
|
|
69
|
+
return raw[start : i + 1]
|
|
70
|
+
|
|
71
|
+
# Truncated — repair by unwinding the open-structure stack
|
|
72
|
+
frag = raw[start:]
|
|
73
|
+
if in_str:
|
|
74
|
+
frag += '"'
|
|
75
|
+
for opener in reversed(stack):
|
|
76
|
+
frag += "}" if opener == "{" else "]"
|
|
77
|
+
return frag
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_json_object(raw: str) -> dict:
|
|
81
|
+
"""
|
|
82
|
+
Extract and parse a JSON object from an arbitrary LLM response.
|
|
83
|
+
|
|
84
|
+
Handles: markdown fences, leading/trailing prose, truncated output.
|
|
85
|
+
Raises LLMJSONError when nothing parseable can be recovered.
|
|
86
|
+
"""
|
|
87
|
+
if not raw or not raw.strip():
|
|
88
|
+
raise LLMJSONError("Empty LLM response", raw)
|
|
89
|
+
|
|
90
|
+
candidate = find_balanced_object(strip_markdown_fences(raw))
|
|
91
|
+
try:
|
|
92
|
+
parsed = json.loads(candidate)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
# Last resort: remove trailing commas (a common LLM slip) and retry
|
|
95
|
+
cleaned = re.sub(r",\s*([}\]])", r"\1", candidate)
|
|
96
|
+
try:
|
|
97
|
+
parsed = json.loads(cleaned)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
raise LLMJSONError(f"LLM returned invalid JSON: {exc}", raw) from exc
|
|
100
|
+
|
|
101
|
+
if not isinstance(parsed, dict):
|
|
102
|
+
raise LLMJSONError(f"Expected JSON object, got {type(parsed).__name__}", raw)
|
|
103
|
+
return parsed
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM provider helpers for Anthropic and OpenAI.
|
|
3
|
+
|
|
4
|
+
All agents talk to the LLM through this module, which provides:
|
|
5
|
+
- provider normalization + API-key resolution
|
|
6
|
+
- automatic retries with exponential backoff on transient failures
|
|
7
|
+
(rate limits, 5xx, timeouts, connection errors)
|
|
8
|
+
- special handling for OpenAI reasoning models (gpt-5*, o*) whose
|
|
9
|
+
reasoning tokens count against max_output_tokens
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
|
|
16
|
+
from selenium_agent.utils.logger import setup_logger
|
|
17
|
+
|
|
18
|
+
logger = setup_logger("LLM")
|
|
19
|
+
|
|
20
|
+
DEFAULT_PROVIDER = "anthropic"
|
|
21
|
+
DEFAULT_MODELS = {
|
|
22
|
+
"anthropic": "claude-sonnet-5",
|
|
23
|
+
"openai": "gpt-5-mini",
|
|
24
|
+
}
|
|
25
|
+
API_KEY_ENV_VARS = {
|
|
26
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
27
|
+
"openai": "OPENAI_API_KEY",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
MAX_RETRIES = 3
|
|
31
|
+
BACKOFF_BASE_SECONDS = 2.0
|
|
32
|
+
|
|
33
|
+
# Error class-name fragments that must NOT be retried — the request itself is bad.
|
|
34
|
+
_NON_RETRYABLE = (
|
|
35
|
+
"authentication", "permissiondenied", "notfound",
|
|
36
|
+
"badrequest", "invalidrequest", "unprocessable",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_provider(provider: str | None) -> str:
|
|
41
|
+
normalized = (provider or DEFAULT_PROVIDER).strip().lower()
|
|
42
|
+
if normalized not in API_KEY_ENV_VARS:
|
|
43
|
+
supported = ", ".join(sorted(API_KEY_ENV_VARS))
|
|
44
|
+
raise ValueError(f"Unsupported provider '{provider}'. Supported providers: {supported}")
|
|
45
|
+
return normalized
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_default_model(provider: str) -> str:
|
|
49
|
+
normalized = normalize_provider(provider)
|
|
50
|
+
return DEFAULT_MODELS[normalized]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_api_key_env_var(provider: str) -> str:
|
|
54
|
+
normalized = normalize_provider(provider)
|
|
55
|
+
return API_KEY_ENV_VARS[normalized]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_missing_api_key_error(provider: str) -> str:
|
|
59
|
+
normalized = normalize_provider(provider)
|
|
60
|
+
env_var = get_api_key_env_var(normalized)
|
|
61
|
+
provider_name = "Anthropic Claude" if normalized == "anthropic" else "OpenAI"
|
|
62
|
+
return (
|
|
63
|
+
f"{provider_name} API key required!\n"
|
|
64
|
+
f"Pass it as: SeleniumAgent(provider='{normalized}', api_key='your-key')\n"
|
|
65
|
+
f"Or set env var: export {env_var}='your-key'\n"
|
|
66
|
+
f"Get your key at: https://console.anthropic.com"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_api_key(provider: str, api_key: str | None = None) -> str | None:
|
|
71
|
+
normalized = normalize_provider(provider)
|
|
72
|
+
if api_key:
|
|
73
|
+
return api_key
|
|
74
|
+
env_var = get_api_key_env_var(normalized)
|
|
75
|
+
return os.environ.get(env_var)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_retryable(exc: Exception) -> bool:
|
|
79
|
+
name = type(exc).__name__.lower()
|
|
80
|
+
if any(fragment in name for fragment in _NON_RETRYABLE):
|
|
81
|
+
return False
|
|
82
|
+
status = getattr(exc, "status_code", None)
|
|
83
|
+
if isinstance(status, int):
|
|
84
|
+
return status == 429 or status >= 500
|
|
85
|
+
# Rate limits, overload, timeouts, connection drops → retry
|
|
86
|
+
return any(f in name for f in ("ratelimit", "overloaded", "timeout", "connection",
|
|
87
|
+
"internalserver", "apierror", "apistatus"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BaseLLMClient:
|
|
91
|
+
"""Minimal interface shared by all provider implementations."""
|
|
92
|
+
|
|
93
|
+
model: str = ""
|
|
94
|
+
|
|
95
|
+
def generate_text(self, system_prompt: str, user_prompt: str, max_tokens: int,
|
|
96
|
+
json_mode: bool = False) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Generate text with automatic retries on transient failures.
|
|
99
|
+
|
|
100
|
+
json_mode=True engages the provider's native structured-output
|
|
101
|
+
mechanism (OpenAI json_object format / Anthropic '{' prefill) so
|
|
102
|
+
responses are valid JSON instead of best-effort prose.
|
|
103
|
+
"""
|
|
104
|
+
last_exc: Exception | None = None
|
|
105
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
106
|
+
try:
|
|
107
|
+
return self._generate_once(system_prompt, user_prompt, max_tokens, json_mode)
|
|
108
|
+
except Exception as exc: # noqa: BLE001 — provider SDKs raise many types
|
|
109
|
+
if not _is_retryable(exc) or attempt == MAX_RETRIES:
|
|
110
|
+
raise
|
|
111
|
+
delay = BACKOFF_BASE_SECONDS * (2 ** (attempt - 1))
|
|
112
|
+
logger.warning(
|
|
113
|
+
f"⚠️ LLM call failed ({type(exc).__name__}) — "
|
|
114
|
+
f"retry {attempt}/{MAX_RETRIES - 1} in {delay:.0f}s"
|
|
115
|
+
)
|
|
116
|
+
time.sleep(delay)
|
|
117
|
+
last_exc = exc
|
|
118
|
+
raise last_exc # pragma: no cover — unreachable
|
|
119
|
+
|
|
120
|
+
def _generate_once(self, system_prompt: str, user_prompt: str, max_tokens: int,
|
|
121
|
+
json_mode: bool = False) -> str:
|
|
122
|
+
raise NotImplementedError
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AnthropicLLMClient(BaseLLMClient):
|
|
126
|
+
"""Anthropic Claude-backed text generation client."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, api_key: str, model: str):
|
|
129
|
+
try:
|
|
130
|
+
anthropic = import_module("anthropic")
|
|
131
|
+
except ModuleNotFoundError as exc:
|
|
132
|
+
raise ModuleNotFoundError(
|
|
133
|
+
"The 'anthropic' package is required for provider='anthropic'. "
|
|
134
|
+
"Install it with: pip install anthropic"
|
|
135
|
+
) from exc
|
|
136
|
+
self.client = anthropic.Anthropic(api_key=api_key)
|
|
137
|
+
self.model = model
|
|
138
|
+
|
|
139
|
+
def _generate_once(self, system_prompt: str, user_prompt: str, max_tokens: int,
|
|
140
|
+
json_mode: bool = False) -> str:
|
|
141
|
+
messages = [{"role": "user", "content": user_prompt}]
|
|
142
|
+
if json_mode:
|
|
143
|
+
# Prefill trick: forcing the assistant to start at '{' makes
|
|
144
|
+
# Claude emit pure JSON with no prose or fences.
|
|
145
|
+
messages.append({"role": "assistant", "content": "{"})
|
|
146
|
+
|
|
147
|
+
message = self.client.messages.create(
|
|
148
|
+
model=self.model,
|
|
149
|
+
max_tokens=max_tokens,
|
|
150
|
+
system=system_prompt,
|
|
151
|
+
messages=messages,
|
|
152
|
+
)
|
|
153
|
+
parts = []
|
|
154
|
+
for content_block in message.content:
|
|
155
|
+
text = getattr(content_block, "text", None)
|
|
156
|
+
if text:
|
|
157
|
+
parts.append(text)
|
|
158
|
+
text = "\n".join(parts).strip()
|
|
159
|
+
if json_mode and not text.startswith("{"):
|
|
160
|
+
text = "{" + text
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class OpenAILLMClient(BaseLLMClient):
|
|
165
|
+
"""OpenAI-backed text generation client (Responses API)."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, api_key: str, model: str):
|
|
168
|
+
try:
|
|
169
|
+
openai = import_module("openai")
|
|
170
|
+
except ModuleNotFoundError as exc:
|
|
171
|
+
raise ModuleNotFoundError(
|
|
172
|
+
"The 'openai' package is required for provider='openai'. "
|
|
173
|
+
"Install it with: pip install openai"
|
|
174
|
+
) from exc
|
|
175
|
+
self.client = openai.OpenAI(api_key=api_key)
|
|
176
|
+
self.model = model
|
|
177
|
+
|
|
178
|
+
def _is_reasoning_model(self) -> bool:
|
|
179
|
+
m = self.model.lower()
|
|
180
|
+
return m.startswith(("gpt-5", "o1", "o3", "o4"))
|
|
181
|
+
|
|
182
|
+
def _generate_once(self, system_prompt: str, user_prompt: str, max_tokens: int,
|
|
183
|
+
json_mode: bool = False) -> str:
|
|
184
|
+
params = {
|
|
185
|
+
"model": self.model,
|
|
186
|
+
"instructions": system_prompt,
|
|
187
|
+
"input": user_prompt,
|
|
188
|
+
"max_output_tokens": max_tokens,
|
|
189
|
+
}
|
|
190
|
+
if json_mode:
|
|
191
|
+
# Native structured output — the model cannot emit invalid JSON.
|
|
192
|
+
# The API requires the word 'json' in the input message itself.
|
|
193
|
+
params["text"] = {"format": {"type": "json_object"}}
|
|
194
|
+
if "json" not in user_prompt.lower():
|
|
195
|
+
params["input"] = user_prompt + "\n\nRespond with a valid JSON object."
|
|
196
|
+
if self._is_reasoning_model():
|
|
197
|
+
# Reasoning tokens count against max_output_tokens — keep effort low
|
|
198
|
+
# and leave headroom so the visible answer isn't starved.
|
|
199
|
+
params["reasoning"] = {"effort": "low"}
|
|
200
|
+
params["max_output_tokens"] = max(max_tokens * 2, 4000)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
response = self.client.responses.create(**params)
|
|
204
|
+
except TypeError:
|
|
205
|
+
# Older SDK without `reasoning`/`text` support
|
|
206
|
+
params.pop("reasoning", None)
|
|
207
|
+
params.pop("text", None)
|
|
208
|
+
response = self.client.responses.create(**params)
|
|
209
|
+
|
|
210
|
+
text = (response.output_text or "").strip()
|
|
211
|
+
if not text and getattr(response, "status", "") == "incomplete":
|
|
212
|
+
# Reasoning consumed the entire budget — one retry with a bigger cap
|
|
213
|
+
params["max_output_tokens"] = params["max_output_tokens"] * 2
|
|
214
|
+
response = self.client.responses.create(**params)
|
|
215
|
+
text = (response.output_text or "").strip()
|
|
216
|
+
|
|
217
|
+
if not text:
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
f"OpenAI model '{self.model}' returned an empty response "
|
|
220
|
+
f"(status={getattr(response, 'status', 'unknown')}). "
|
|
221
|
+
f"Try a larger max_tokens or a non-reasoning model like gpt-4o-mini."
|
|
222
|
+
)
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def create_llm_client(
|
|
227
|
+
provider: str,
|
|
228
|
+
api_key: str,
|
|
229
|
+
model: str | None = None,
|
|
230
|
+
) -> BaseLLMClient:
|
|
231
|
+
"""Create a provider-specific LLM client."""
|
|
232
|
+
normalized = normalize_provider(provider)
|
|
233
|
+
resolved_model = model or get_default_model(normalized)
|
|
234
|
+
|
|
235
|
+
if normalized == "anthropic":
|
|
236
|
+
return AnthropicLLMClient(api_key=api_key, model=resolved_model)
|
|
237
|
+
if normalized == "openai":
|
|
238
|
+
return OpenAILLMClient(api_key=api_key, model=resolved_model)
|
|
239
|
+
|
|
240
|
+
raise ValueError(f"Unsupported provider '{provider}'")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logger utility for Selenium AI Agent
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logger(name: str, log_to_file: bool = False) -> logging.Logger:
|
|
11
|
+
logger = logging.getLogger(name)
|
|
12
|
+
logger.setLevel(logging.DEBUG)
|
|
13
|
+
|
|
14
|
+
if logger.handlers:
|
|
15
|
+
return logger
|
|
16
|
+
|
|
17
|
+
formatter = logging.Formatter(
|
|
18
|
+
"%(asctime)s | %(name)s | %(levelname)s | %(message)s",
|
|
19
|
+
datefmt="%H:%M:%S"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Console handler
|
|
23
|
+
console_handler = logging.StreamHandler()
|
|
24
|
+
console_handler.setLevel(logging.INFO)
|
|
25
|
+
console_handler.setFormatter(formatter)
|
|
26
|
+
logger.addHandler(console_handler)
|
|
27
|
+
|
|
28
|
+
# Optional file handler
|
|
29
|
+
if log_to_file:
|
|
30
|
+
os.makedirs("logs", exist_ok=True)
|
|
31
|
+
log_file = f"logs/agent_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
32
|
+
file_handler = logging.FileHandler(log_file)
|
|
33
|
+
file_handler.setLevel(logging.DEBUG)
|
|
34
|
+
file_handler.setFormatter(formatter)
|
|
35
|
+
logger.addHandler(file_handler)
|
|
36
|
+
|
|
37
|
+
return logger
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Path helpers for generated Selenium agent files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_output_root(output_dir: str) -> Path:
|
|
9
|
+
"""Return the absolute root directory for generated files."""
|
|
10
|
+
return Path(output_dir).resolve()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_input_path(file_path: str, output_dir: str) -> Path:
|
|
14
|
+
"""
|
|
15
|
+
Resolve an input file path from either the current working directory or output_dir.
|
|
16
|
+
|
|
17
|
+
This supports both:
|
|
18
|
+
- generated_tests/tests/test_login.py
|
|
19
|
+
- tests/test_login.py when output_dir is generated_tests
|
|
20
|
+
"""
|
|
21
|
+
candidate = Path(file_path)
|
|
22
|
+
if candidate.is_absolute():
|
|
23
|
+
return candidate.resolve()
|
|
24
|
+
|
|
25
|
+
output_root = get_output_root(output_dir)
|
|
26
|
+
|
|
27
|
+
# Paths that already include the output_dir prefix resolve against it
|
|
28
|
+
# first — avoids the CWD accidentally shadowing the intended target.
|
|
29
|
+
if candidate.parts and candidate.parts[0] == output_root.name:
|
|
30
|
+
prefixed = (output_root.parent / candidate).resolve()
|
|
31
|
+
if prefixed.exists():
|
|
32
|
+
return prefixed
|
|
33
|
+
|
|
34
|
+
if candidate.exists():
|
|
35
|
+
return candidate.resolve()
|
|
36
|
+
|
|
37
|
+
if candidate.parts and candidate.parts[0] == output_root.name:
|
|
38
|
+
return (output_root.parent / candidate).resolve()
|
|
39
|
+
|
|
40
|
+
return (output_root / candidate).resolve()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def safe_output_path(output_dir: str, filename: str) -> Path:
|
|
44
|
+
"""Resolve a model-provided filename and ensure it stays inside output_dir."""
|
|
45
|
+
relative_path = Path(filename)
|
|
46
|
+
if relative_path.is_absolute():
|
|
47
|
+
raise ValueError(f"Refusing to write absolute path from model output: {filename}")
|
|
48
|
+
|
|
49
|
+
output_root = get_output_root(output_dir)
|
|
50
|
+
destination = (output_root / relative_path).resolve()
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
destination.relative_to(output_root)
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise ValueError(f"Refusing to write outside output_dir: {filename}") from exc
|
|
56
|
+
|
|
57
|
+
return destination
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SPEC WRITER
|
|
3
|
+
===========
|
|
4
|
+
Renders a structured test plan (the Planner's JSON) as a human-readable
|
|
5
|
+
Markdown spec and persists both to the `specs/` directory — mirroring how
|
|
6
|
+
Playwright's planner agent saves Markdown test plans.
|
|
7
|
+
|
|
8
|
+
specs/
|
|
9
|
+
├── login-page.md ← human-readable plan (review / edit / re-run)
|
|
10
|
+
└── login-page.json ← machine-readable plan (input to the Generator)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slugify(text: str, max_len: int = 60) -> str:
|
|
22
|
+
"""'Test the Login page!' → 'test-the-login-page'"""
|
|
23
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
24
|
+
return slug[:max_len].rstrip("-") or "test-plan"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_markdown(plan: dict, instruction: str = "") -> str:
|
|
28
|
+
"""Render a plan dict (pytest or bdd mode) as a Markdown test plan."""
|
|
29
|
+
mode = plan.get("mode", "pytest")
|
|
30
|
+
url = plan.get("url", "")
|
|
31
|
+
scenarios = plan.get("test_scenarios") or plan.get("scenarios") or []
|
|
32
|
+
pages = plan.get("page_objects_needed", [])
|
|
33
|
+
locators = plan.get("locators", [])
|
|
34
|
+
|
|
35
|
+
lines = [
|
|
36
|
+
f"# Test Plan — {plan.get('feature_title') or instruction or 'Selenium Test Suite'}",
|
|
37
|
+
"",
|
|
38
|
+
f"- **Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
|
39
|
+
f"- **Mode**: {mode}",
|
|
40
|
+
f"- **Target URL**: {url or 'n/a'}",
|
|
41
|
+
f"- **Browser**: {plan.get('browser', 'chrome')} (headless={plan.get('headless', False)})",
|
|
42
|
+
"",
|
|
43
|
+
"## Page Objects",
|
|
44
|
+
"",
|
|
45
|
+
]
|
|
46
|
+
for page in pages:
|
|
47
|
+
lines.append(f"- `{page}`")
|
|
48
|
+
if not pages:
|
|
49
|
+
lines.append("- _none planned_")
|
|
50
|
+
|
|
51
|
+
lines += ["", "## Scenarios", ""]
|
|
52
|
+
for idx, sc in enumerate(scenarios, 1):
|
|
53
|
+
name = sc.get("name", f"Scenario {idx}")
|
|
54
|
+
lines.append(f"### {sc.get('id', f'TC{idx:03d}')} — {name}")
|
|
55
|
+
lines.append("")
|
|
56
|
+
if sc.get("description"):
|
|
57
|
+
lines.append(f"{sc['description']}")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("**Steps:**")
|
|
60
|
+
for step in sc.get("steps", []):
|
|
61
|
+
if isinstance(step, dict): # BDD step: {"type": "Given", "text": "..."}
|
|
62
|
+
lines.append(f"1. **{step.get('type', '')}** {step.get('text', '')}")
|
|
63
|
+
else:
|
|
64
|
+
lines.append(f"1. {step}")
|
|
65
|
+
if sc.get("expected_result"):
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append(f"**Expected:** {sc['expected_result']}")
|
|
68
|
+
if sc.get("test_data"):
|
|
69
|
+
lines.append("")
|
|
70
|
+
lines.append(f"**Test data:** `{json.dumps(sc['test_data'])}`")
|
|
71
|
+
lines.append("")
|
|
72
|
+
|
|
73
|
+
if locators:
|
|
74
|
+
lines += [
|
|
75
|
+
"## Locators (from live DOM scan)",
|
|
76
|
+
"",
|
|
77
|
+
"| Page Object | Element | Selector | Wait |",
|
|
78
|
+
"|---|---|---|---|",
|
|
79
|
+
]
|
|
80
|
+
for loc in locators:
|
|
81
|
+
selector = loc.get("css") or loc.get("xpath") or ""
|
|
82
|
+
lines.append(
|
|
83
|
+
f"| {loc.get('page_object', '')} | {loc.get('element', '')} "
|
|
84
|
+
f"| `{selector}` | {loc.get('wait_condition', 'visible')} |"
|
|
85
|
+
)
|
|
86
|
+
lines.append("")
|
|
87
|
+
|
|
88
|
+
if plan.get("notes"):
|
|
89
|
+
lines += ["## Notes", "", str(plan["notes"]), ""]
|
|
90
|
+
|
|
91
|
+
return "\n".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def save_spec(plan: dict, instruction: str, specs_dir: str | Path = "specs") -> dict:
|
|
95
|
+
"""
|
|
96
|
+
Save the plan as Markdown + JSON in specs_dir.
|
|
97
|
+
Returns {"markdown": path, "json": path}.
|
|
98
|
+
"""
|
|
99
|
+
specs_root = Path(specs_dir)
|
|
100
|
+
specs_root.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
slug = slugify(plan.get("feature_name") or instruction)
|
|
103
|
+
md_path = specs_root / f"{slug}.md"
|
|
104
|
+
json_path = specs_root / f"{slug}.json"
|
|
105
|
+
|
|
106
|
+
md_path.write_text(render_markdown(plan, instruction), encoding="utf-8")
|
|
107
|
+
json_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
return {"markdown": str(md_path), "json": str(json_path)}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_plan(plan_file: str | Path) -> dict:
|
|
113
|
+
"""Load a saved plan (.json) for the Generator to consume."""
|
|
114
|
+
path = Path(plan_file)
|
|
115
|
+
if not path.exists():
|
|
116
|
+
raise FileNotFoundError(f"Plan file not found: {path}")
|
|
117
|
+
if path.suffix == ".md":
|
|
118
|
+
# Allow pointing at the markdown twin — load the sibling .json
|
|
119
|
+
sibling = path.with_suffix(".json")
|
|
120
|
+
if not sibling.exists():
|
|
121
|
+
raise FileNotFoundError(
|
|
122
|
+
f"Markdown plans are for humans — the Generator needs the JSON twin, "
|
|
123
|
+
f"which was not found: {sibling}"
|
|
124
|
+
)
|
|
125
|
+
path = sibling
|
|
126
|
+
return json.loads(path.read_text(encoding="utf-8"))
|