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