fusiontest 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {fusiontest-0.2.1 → fusiontest-0.2.2}/PKG-INFO +1 -1
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/action_model.py +9 -4
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/playwright_adapter.py +25 -0
- fusiontest-0.2.2/fusiontest/discovery/goal_generator.py +287 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/PKG-INFO +1 -1
- {fusiontest-0.2.1 → fusiontest-0.2.2}/pyproject.toml +1 -1
- fusiontest-0.2.1/fusiontest/discovery/goal_generator.py +0 -695
- {fusiontest-0.2.1 → fusiontest-0.2.2}/README.md +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/cli.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/goal_verifier.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/replay.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/runner.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/screen_parser.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/secrets.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/tokens.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/macos_adapter.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/windows_adapter.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/discovery/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/guardrails/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/guardrails/engine.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/android_adapter.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/ios_adapter.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/recording/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/recording/recorder.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/reporting/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/reporting/reporter.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/__init__.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/data_collector.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/dataset_builder.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/trainer.py +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/SOURCES.txt +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/dependency_links.txt +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/entry_points.txt +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/not-zip-safe +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/requires.txt +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/top_level.txt +0 -0
- {fusiontest-0.2.1 → fusiontest-0.2.2}/setup.cfg +0 -0
|
@@ -320,6 +320,7 @@ class LLMRequest:
|
|
|
320
320
|
reminder: str = "" # appended to the prompt for backends without a JSON mode
|
|
321
321
|
json_object: bool = False # OpenAI-compatible response_format={"type": "json_object"}
|
|
322
322
|
empty: str = "" # returned when the model produces no text
|
|
323
|
+
max_output_tokens: int = LLM_MAX_OUTPUT_TOKENS # includes hidden reasoning/thinking
|
|
323
324
|
|
|
324
325
|
|
|
325
326
|
class LLMCaller:
|
|
@@ -341,6 +342,8 @@ class LLMCaller:
|
|
|
341
342
|
self.temperature = temperature
|
|
342
343
|
self.max_retries_per_backend = max_retries_per_backend
|
|
343
344
|
self.backoff_seconds = backoff_seconds
|
|
345
|
+
# "backend:model" of the entry that answered the last call.
|
|
346
|
+
self.last_backend: str = ""
|
|
344
347
|
# Per-(backend, model) client cache. Lazily populated.
|
|
345
348
|
self._clients: dict[tuple[str, str], tuple[Any, str]] = {}
|
|
346
349
|
|
|
@@ -493,7 +496,9 @@ class LLMCaller:
|
|
|
493
496
|
|
|
494
497
|
for attempt in range(self.max_retries_per_backend + 1):
|
|
495
498
|
try:
|
|
496
|
-
|
|
499
|
+
answer = self._call_llm_single(prompt, backend, model, request)
|
|
500
|
+
self.last_backend = f"{backend}:{model or default_model_for_backend(backend)}"
|
|
501
|
+
return answer
|
|
497
502
|
except Exception as err: # noqa: BLE001
|
|
498
503
|
last_error = err
|
|
499
504
|
transient = _is_transient_error(err)
|
|
@@ -572,7 +577,7 @@ class LLMCaller:
|
|
|
572
577
|
{"role": "user", "content": prompt},
|
|
573
578
|
],
|
|
574
579
|
temperature=self.temperature,
|
|
575
|
-
max_tokens=
|
|
580
|
+
max_tokens=request.max_output_tokens,
|
|
576
581
|
**({"response_format": {"type": "json_object"}} if request.json_object else {}),
|
|
577
582
|
**openai_compat_extra_args(resolved_model),
|
|
578
583
|
)
|
|
@@ -585,7 +590,7 @@ class LLMCaller:
|
|
|
585
590
|
if backend == "claude":
|
|
586
591
|
resp = client.messages.create(
|
|
587
592
|
model=resolved_model,
|
|
588
|
-
max_tokens=
|
|
593
|
+
max_tokens=request.max_output_tokens,
|
|
589
594
|
system=request.system,
|
|
590
595
|
messages=[{"role": "user", "content": prompt + request.reminder}],
|
|
591
596
|
)
|
|
@@ -603,7 +608,7 @@ class LLMCaller:
|
|
|
603
608
|
config=genai_types.GenerateContentConfig(
|
|
604
609
|
system_instruction=request.system,
|
|
605
610
|
temperature=self.temperature,
|
|
606
|
-
max_output_tokens=
|
|
611
|
+
max_output_tokens=request.max_output_tokens,
|
|
607
612
|
),
|
|
608
613
|
)
|
|
609
614
|
meta = getattr(resp, "usage_metadata", None)
|
|
@@ -140,6 +140,31 @@ class PlaywrightAdapter:
|
|
|
140
140
|
|
|
141
141
|
# ── Screen reading ─────────────────────────────────────────────────────────
|
|
142
142
|
|
|
143
|
+
def site_links(self, limit: int = 10) -> list[str]:
|
|
144
|
+
"""Same-origin page URLs linked from the header/navigation (fallback: any link)."""
|
|
145
|
+
return self._get_loop().run_until_complete(self._async_site_links(limit))
|
|
146
|
+
|
|
147
|
+
async def _async_site_links(self, limit: int) -> list[str]:
|
|
148
|
+
from urllib.parse import urldefrag, urljoin, urlsplit # noqa: PLC0415
|
|
149
|
+
|
|
150
|
+
hrefs: list[str] = await self._page.locator("header a[href], nav a[href]").evaluate_all(
|
|
151
|
+
"els => els.map(e => e.getAttribute('href'))"
|
|
152
|
+
)
|
|
153
|
+
if not hrefs:
|
|
154
|
+
hrefs = await self._page.locator("a[href]").evaluate_all(
|
|
155
|
+
"els => els.map(e => e.getAttribute('href'))"
|
|
156
|
+
)
|
|
157
|
+
base = self._page.url
|
|
158
|
+
origin = urlsplit(base).netloc
|
|
159
|
+
links: list[str] = []
|
|
160
|
+
for href in hrefs:
|
|
161
|
+
if not href or href.startswith(("mailto:", "tel:", "javascript:")):
|
|
162
|
+
continue
|
|
163
|
+
url = urldefrag(urljoin(base, href)).url.rstrip("/")
|
|
164
|
+
if urlsplit(url).netloc == origin and url != base.rstrip("/") and url not in links:
|
|
165
|
+
links.append(url)
|
|
166
|
+
return links[:limit]
|
|
167
|
+
|
|
143
168
|
def current_url(self) -> str:
|
|
144
169
|
"""URL of the current page ("" before start). Used for secret site binding."""
|
|
145
170
|
return self._page.url if self._page else ""
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fusiontest/discovery/goal_generator.py
|
|
3
|
+
|
|
4
|
+
Autonomous goal discovery: propose test goals for a site from what it
|
|
5
|
+
actually contains.
|
|
6
|
+
|
|
7
|
+
1. ``snapshot_site`` opens the site in a headless browser and captures the
|
|
8
|
+
start page plus up to a few pages linked from its navigation, using the
|
|
9
|
+
same screen representation the runner uses (headings, text, form fields,
|
|
10
|
+
buttons).
|
|
11
|
+
2. ``GoalGenerator.generate`` asks an LLM (through the shared fallback chain)
|
|
12
|
+
for goals, each quoting *evidence* from the captured pages.
|
|
13
|
+
3. Goals whose evidence isn't on those pages are dropped — the model may not
|
|
14
|
+
invent features (login, search, consent checkboxes…) the site doesn't have.
|
|
15
|
+
|
|
16
|
+
There is no template fallback: if no LLM can answer, generation fails with the
|
|
17
|
+
underlying error rather than presenting generic goals as "discovered".
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Literal
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
from fusiontest.core.action_model import (
|
|
31
|
+
LLMCaller,
|
|
32
|
+
LLMRequest,
|
|
33
|
+
LLMUnavailableError,
|
|
34
|
+
_is_ollama_reachable,
|
|
35
|
+
)
|
|
36
|
+
from fusiontest.core.replay import select_evidence
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
GoalCategory = Literal["critical_path", "happy_path", "edge_cases"]
|
|
41
|
+
_CATEGORIES = ("critical_path", "happy_path", "edge_cases")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GoalGenerationError(RuntimeError):
|
|
45
|
+
"""Goals couldn't be generated (site unreachable, no LLM, unusable answer)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class DiscoveredGoal:
|
|
50
|
+
id: str
|
|
51
|
+
name: str # short 5-8 word title for the YAML output
|
|
52
|
+
text: str
|
|
53
|
+
category: GoalCategory
|
|
54
|
+
confidence: float # 0.0 – 1.0
|
|
55
|
+
selected: bool = True # user can deselect before running
|
|
56
|
+
edited: bool = False # has the user overridden the text?
|
|
57
|
+
evidence: str = "" # quote from the site content the goal relies on
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class GoalGeneratorConfig:
|
|
62
|
+
max_goals: int = 20
|
|
63
|
+
max_pages: int = 8 # start page + pages linked from navigation
|
|
64
|
+
max_chars_per_page: int = 4000
|
|
65
|
+
temperature: float = 0.3
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SiteSnapshot:
|
|
70
|
+
url: str
|
|
71
|
+
pages: list[tuple[str, str]] = field(default_factory=list) # (url, screen text)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def content(self) -> str:
|
|
75
|
+
return "\n\n".join(f"=== PAGE {urlsplit(u).path or '/'} ===\n{text}" for u, text in self.pages)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Site snapshot ─────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def snapshot_site(url: str, config: GoalGeneratorConfig | None = None) -> SiteSnapshot:
|
|
81
|
+
"""Capture the start page and pages linked from its navigation."""
|
|
82
|
+
from fusiontest.desktop.playwright_adapter import PlaywrightAdapter # noqa: PLC0415
|
|
83
|
+
|
|
84
|
+
cfg = config or GoalGeneratorConfig()
|
|
85
|
+
snapshot = SiteSnapshot(url=url)
|
|
86
|
+
adapter = PlaywrightAdapter(headless=True)
|
|
87
|
+
try:
|
|
88
|
+
adapter.start(url)
|
|
89
|
+
snapshot.pages.append((url, _page_text(adapter, cfg)))
|
|
90
|
+
for link in adapter.site_links(limit=cfg.max_pages - 1):
|
|
91
|
+
try:
|
|
92
|
+
adapter.navigate(link)
|
|
93
|
+
snapshot.pages.append((link, _page_text(adapter, cfg)))
|
|
94
|
+
except Exception as exc: # noqa: BLE001 — one bad page shouldn't sink discovery
|
|
95
|
+
logger.warning("Discovery: skipping %s (%s)", link, exc)
|
|
96
|
+
except Exception as exc: # noqa: BLE001
|
|
97
|
+
raise GoalGenerationError(f"Could not open {url}: {exc}") from exc
|
|
98
|
+
finally:
|
|
99
|
+
try:
|
|
100
|
+
adapter.stop()
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
pass
|
|
103
|
+
logger.info("Discovery snapshot of %s: %d page(s), %d chars",
|
|
104
|
+
url, len(snapshot.pages), len(snapshot.content))
|
|
105
|
+
return snapshot
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _page_text(adapter, cfg: GoalGeneratorConfig) -> str:
|
|
109
|
+
_, screen = adapter.get_screen()
|
|
110
|
+
return screen[: cfg.max_chars_per_page]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── Prompt ────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
_SYSTEM_PROMPT = """\
|
|
116
|
+
You are a senior QA engineer writing automated UI test goals for a specific website.
|
|
117
|
+
You are given the content of the site's pages as an accessibility snapshot:
|
|
118
|
+
interactive elements (links, buttons, fields with their labels) and page text.
|
|
119
|
+
|
|
120
|
+
Rules:
|
|
121
|
+
- Only write goals for features that appear in the provided content. Do NOT
|
|
122
|
+
assume features that aren't shown — no login, sign-up, search, checkout,
|
|
123
|
+
account, or consent-checkbox goals unless those controls appear in the content.
|
|
124
|
+
- Each goal is one complete, self-contained action sentence a tester can follow,
|
|
125
|
+
e.g. "Navigate to the Pricing page and verify all three plan tiers are displayed".
|
|
126
|
+
- Refer to things by their visible text (link, button, heading or field label).
|
|
127
|
+
No CSS selectors, IDs, or code.
|
|
128
|
+
- Do not submit real forms, send messages, or make purchases: this may be a
|
|
129
|
+
live production site. For forms, verify the fields and client-side validation
|
|
130
|
+
(e.g. required-field errors) instead of submitting successfully.
|
|
131
|
+
- Cover navigation, key content, forms and validation — as far as the site has them.
|
|
132
|
+
- evidence: a short EXACT quote (3-80 characters) copied from the provided
|
|
133
|
+
content that the goal depends on — a heading, link, button or field label.
|
|
134
|
+
|
|
135
|
+
Return ONLY a JSON object: {"goals": [ ... ]}. Each goal has exactly these keys:
|
|
136
|
+
name (short 5-8 word title)
|
|
137
|
+
text (the full goal sentence)
|
|
138
|
+
category ("critical_path" | "happy_path" | "edge_cases")
|
|
139
|
+
confidence (number 0.0-1.0: how clearly the content supports this goal)
|
|
140
|
+
evidence (exact quote from the content)
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
_REQUEST = LLMRequest(
|
|
144
|
+
system=_SYSTEM_PROMPT,
|
|
145
|
+
reminder='\n\nIMPORTANT: respond with ONLY the JSON object {"goals": [...]}. No other text.',
|
|
146
|
+
json_object=True,
|
|
147
|
+
empty="{}",
|
|
148
|
+
# 20 goals of JSON plus the model's hidden reasoning/thinking.
|
|
149
|
+
max_output_tokens=8192,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_user_message(snapshot: SiteSnapshot, max_goals: int) -> str:
|
|
154
|
+
return (
|
|
155
|
+
f"Site: {snapshot.url}\n\n"
|
|
156
|
+
f"SITE CONTENT ({len(snapshot.pages)} page(s)):\n{snapshot.content}\n\n"
|
|
157
|
+
f"Write up to {max_goals} test goals for this site, grounded in the content above, "
|
|
158
|
+
f"across critical_path (the site's most important flows), happy_path (navigation "
|
|
159
|
+
f"and features) and edge_cases (validation and error states)."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── Parsing ───────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _derive_name(text: str) -> str:
|
|
166
|
+
"""Fallback: condense a goal sentence into a short 5-8 word title."""
|
|
167
|
+
cleaned = re.sub(
|
|
168
|
+
r"^(verify\s+(that\s+)?|navigate\s+to\s+(the\s+)?|check\s+(that\s+)?|"
|
|
169
|
+
r"attempt\s+to\s+|click\s+|submit\s+|open\s+(the\s+)?|ensure\s+(that\s+)?)",
|
|
170
|
+
"",
|
|
171
|
+
text,
|
|
172
|
+
flags=re.I,
|
|
173
|
+
).strip()
|
|
174
|
+
words = cleaned.split()
|
|
175
|
+
name = " ".join(words[:7])
|
|
176
|
+
if len(words) > 7:
|
|
177
|
+
name += "…"
|
|
178
|
+
return name.capitalize()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_goals(raw: str, max_goals: int, content: str) -> list[DiscoveredGoal]:
|
|
182
|
+
"""Parse the LLM's JSON and keep only goals whose evidence is in the content."""
|
|
183
|
+
raw = re.sub(r"^```[a-z]*\n?|\n?```$", "", raw.strip(), flags=re.M)
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(raw)
|
|
186
|
+
except json.JSONDecodeError as exc:
|
|
187
|
+
raise GoalGenerationError(f"The model's answer wasn't valid JSON: {exc}") from exc
|
|
188
|
+
items = data if isinstance(data, list) else data.get("goals", [])
|
|
189
|
+
|
|
190
|
+
goals: list[DiscoveredGoal] = []
|
|
191
|
+
dropped: list[str] = []
|
|
192
|
+
for item in items:
|
|
193
|
+
if not isinstance(item, dict):
|
|
194
|
+
continue
|
|
195
|
+
text = str(item.get("text", "")).strip()
|
|
196
|
+
if not text:
|
|
197
|
+
continue
|
|
198
|
+
evidence = select_evidence([str(item.get("evidence", ""))], content, limit=1)
|
|
199
|
+
if not evidence:
|
|
200
|
+
dropped.append(text)
|
|
201
|
+
continue
|
|
202
|
+
category = item.get("category", "happy_path")
|
|
203
|
+
if category not in _CATEGORIES:
|
|
204
|
+
category = "happy_path"
|
|
205
|
+
try:
|
|
206
|
+
confidence = max(0.0, min(1.0, float(item.get("confidence", 0.8))))
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
confidence = 0.8
|
|
209
|
+
goals.append(DiscoveredGoal(
|
|
210
|
+
id=f"goal-{len(goals):03d}",
|
|
211
|
+
name=str(item.get("name", "")).strip() or _derive_name(text),
|
|
212
|
+
text=text,
|
|
213
|
+
category=category, # type: ignore[arg-type]
|
|
214
|
+
confidence=confidence,
|
|
215
|
+
selected=confidence >= 0.75,
|
|
216
|
+
evidence=evidence[0],
|
|
217
|
+
))
|
|
218
|
+
if len(goals) >= max_goals:
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
if dropped:
|
|
222
|
+
logger.info("Dropped %d goal(s) not grounded in the site content: %s",
|
|
223
|
+
len(dropped), "; ".join(d[:60] for d in dropped))
|
|
224
|
+
return goals
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Backend choice ────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
_BACKEND_KEYS = {"groq": "GROQ_API_KEY", "claude": "ANTHROPIC_API_KEY",
|
|
230
|
+
"gemini": "GEMINI_API_KEY", "gpt4o": "OPENAI_API_KEY"}
|
|
231
|
+
_BACKEND_ALIASES = {"openai": "gpt4o"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _primary_backend(preferred: str | None) -> str:
|
|
235
|
+
"""The user's backend if usable, else the first one with credentials."""
|
|
236
|
+
choice = (preferred or "").strip().lower()
|
|
237
|
+
choice = _BACKEND_ALIASES.get(choice, choice)
|
|
238
|
+
if choice in _BACKEND_KEYS and os.getenv(_BACKEND_KEYS[choice]):
|
|
239
|
+
return choice
|
|
240
|
+
if choice == "ollama" and _is_ollama_reachable():
|
|
241
|
+
return "ollama"
|
|
242
|
+
for backend, key in _BACKEND_KEYS.items():
|
|
243
|
+
if os.getenv(key):
|
|
244
|
+
return backend
|
|
245
|
+
if _is_ollama_reachable():
|
|
246
|
+
return "ollama"
|
|
247
|
+
raise GoalGenerationError(
|
|
248
|
+
"No LLM configured: set GROQ_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or "
|
|
249
|
+
"OPENAI_API_KEY (or run Ollama)."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── Public interface ──────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
class GoalGenerator:
|
|
256
|
+
"""Generates prioritised, site-specific test goals for a given URL."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, config: GoalGeneratorConfig | None = None) -> None:
|
|
259
|
+
self.config = config or GoalGeneratorConfig()
|
|
260
|
+
# Set after generate(): "backend:model" that produced the goals.
|
|
261
|
+
self.last_backend_used: str = ""
|
|
262
|
+
|
|
263
|
+
def generate(
|
|
264
|
+
self,
|
|
265
|
+
url: str,
|
|
266
|
+
preferred_backend: str | None = None,
|
|
267
|
+
snapshot: SiteSnapshot | None = None,
|
|
268
|
+
) -> list[DiscoveredGoal]:
|
|
269
|
+
"""Goals grounded in the site's content. Raises GoalGenerationError."""
|
|
270
|
+
snapshot = snapshot or snapshot_site(url, self.config)
|
|
271
|
+
if not snapshot.content.strip():
|
|
272
|
+
raise GoalGenerationError(f"No content could be read from {url}")
|
|
273
|
+
|
|
274
|
+
caller = LLMCaller(_primary_backend(preferred_backend), temperature=self.config.temperature)
|
|
275
|
+
try:
|
|
276
|
+
raw, _ = caller._call_llm(_build_user_message(snapshot, self.config.max_goals), _REQUEST)
|
|
277
|
+
except LLMUnavailableError as exc:
|
|
278
|
+
raise GoalGenerationError(str(exc)) from exc
|
|
279
|
+
self.last_backend_used = caller.last_backend
|
|
280
|
+
|
|
281
|
+
goals = _parse_goals(raw, self.config.max_goals, snapshot.content)
|
|
282
|
+
if not goals:
|
|
283
|
+
raise GoalGenerationError(
|
|
284
|
+
"The model didn't propose any goals grounded in the site's content."
|
|
285
|
+
)
|
|
286
|
+
logger.info("Generated %d goal(s) for %s via %s", len(goals), url, self.last_backend_used)
|
|
287
|
+
return goals
|
|
@@ -1,695 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
fusiontest/discovery/goal_generator.py
|
|
3
|
-
|
|
4
|
-
LLM-powered test goal generator.
|
|
5
|
-
|
|
6
|
-
Given a URL (and optionally scraped page HTML), generates prioritised
|
|
7
|
-
natural-language test goals across three categories:
|
|
8
|
-
|
|
9
|
-
* critical_path — signup, login, checkout, core user actions
|
|
10
|
-
* happy_path — navigation, forms, standard CRUD flows
|
|
11
|
-
* edge_cases — empty inputs, invalid data, error states
|
|
12
|
-
|
|
13
|
-
LLM priority order:
|
|
14
|
-
1. Anthropic Claude (ANTHROPIC_API_KEY)
|
|
15
|
-
2. OpenAI GPT-4o (OPENAI_API_KEY)
|
|
16
|
-
3. Heuristic fallback (no key needed — generic templates)
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import json
|
|
22
|
-
import logging
|
|
23
|
-
import os
|
|
24
|
-
import re
|
|
25
|
-
from dataclasses import dataclass
|
|
26
|
-
from typing import Literal
|
|
27
|
-
|
|
28
|
-
from fusiontest.core.action_model import default_model_for_backend
|
|
29
|
-
|
|
30
|
-
logger = logging.getLogger(__name__)
|
|
31
|
-
|
|
32
|
-
GoalCategory = Literal["critical_path", "happy_path", "edge_cases"]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@dataclass
|
|
36
|
-
class DiscoveredGoal:
|
|
37
|
-
id: str
|
|
38
|
-
name: str # short 5-8 word title for the YAML output
|
|
39
|
-
text: str
|
|
40
|
-
category: GoalCategory
|
|
41
|
-
confidence: float # 0.0 – 1.0
|
|
42
|
-
selected: bool = True # user can deselect before running
|
|
43
|
-
edited: bool = False # has the user overridden the text?
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@dataclass
|
|
47
|
-
class GoalGeneratorConfig:
|
|
48
|
-
openai_model: str = "gpt-4o"
|
|
49
|
-
claude_model: str = "" # empty = auto-detect from API
|
|
50
|
-
max_goals: int = 20
|
|
51
|
-
temperature: float = 0.3
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# ── Page scraper ──────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
def _scrape_page_text(url: str, timeout: float = 12.0) -> tuple[str, str, str]:
|
|
57
|
-
"""
|
|
58
|
-
Fetch page HTML and return (title, nav_links, element_summary).
|
|
59
|
-
Extracts richer signal: meta description, nav items, headings, CTA buttons.
|
|
60
|
-
Never raises.
|
|
61
|
-
"""
|
|
62
|
-
try:
|
|
63
|
-
import httpx
|
|
64
|
-
|
|
65
|
-
resp = httpx.get(
|
|
66
|
-
url,
|
|
67
|
-
timeout=timeout,
|
|
68
|
-
follow_redirects=True,
|
|
69
|
-
headers={
|
|
70
|
-
"User-Agent": (
|
|
71
|
-
"Mozilla/5.0 (compatible; FusionTest-Discovery/0.1; "
|
|
72
|
-
"+https://fusiontest.fusionleap.io)"
|
|
73
|
-
),
|
|
74
|
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
75
|
-
"Accept-Language": "en-US,en;q=0.5",
|
|
76
|
-
},
|
|
77
|
-
)
|
|
78
|
-
html = resp.text
|
|
79
|
-
|
|
80
|
-
# Title
|
|
81
|
-
m = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I)
|
|
82
|
-
title = m.group(1).strip() if m else ""
|
|
83
|
-
|
|
84
|
-
# Meta description
|
|
85
|
-
m2 = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\']([^"\']{1,200})', html, re.I)
|
|
86
|
-
meta_desc = m2.group(1).strip() if m2 else ""
|
|
87
|
-
|
|
88
|
-
# H1 headings
|
|
89
|
-
headings = re.findall(r"<h[12][^>]*>([^<]{2,80})</h[12]>", html, re.I)
|
|
90
|
-
headings_clean = [re.sub(r"\s+", " ", h).strip() for h in headings[:6]]
|
|
91
|
-
|
|
92
|
-
# Nav links
|
|
93
|
-
nav_block = re.search(r"<nav[^>]*>(.*?)</nav>", html, re.I | re.S)
|
|
94
|
-
nav_links: list[str] = []
|
|
95
|
-
if nav_block:
|
|
96
|
-
nav_links = re.findall(r"<a[^>]*>([^<]{2,40})</a>", nav_block.group(1), re.I)
|
|
97
|
-
nav_links = [re.sub(r"\s+", " ", t).strip() for t in nav_links if t.strip()][:12]
|
|
98
|
-
|
|
99
|
-
# Forms
|
|
100
|
-
forms = len(re.findall(r"<form", html, re.I))
|
|
101
|
-
|
|
102
|
-
# Input types and placeholders
|
|
103
|
-
inputs = re.findall(
|
|
104
|
-
r'<input[^>]*type=["\']([^"\']+)["\'][^>]*(?:placeholder=["\']([^"\']*)["\'])?',
|
|
105
|
-
html, re.I,
|
|
106
|
-
)
|
|
107
|
-
input_types = list({t for t, _ in inputs if t.lower() not in ("hidden", "submit", "button")})
|
|
108
|
-
placeholders = [p for _, p in inputs if p.strip()][:6]
|
|
109
|
-
|
|
110
|
-
# CTA / action buttons
|
|
111
|
-
buttons = re.findall(r"<button[^>]*>([^<]{2,50})</button>", html, re.I)
|
|
112
|
-
buttons_clean = [re.sub(r"\s+", " ", b).strip() for b in buttons[:8]]
|
|
113
|
-
|
|
114
|
-
# Build structured summary
|
|
115
|
-
parts: list[str] = []
|
|
116
|
-
if meta_desc:
|
|
117
|
-
parts.append(f"Description: {meta_desc}")
|
|
118
|
-
if headings_clean:
|
|
119
|
-
parts.append(f"Headings: {', '.join(headings_clean)}")
|
|
120
|
-
if nav_links:
|
|
121
|
-
parts.append(f"Navigation: {', '.join(nav_links)}")
|
|
122
|
-
if forms:
|
|
123
|
-
parts.append(f"{forms} form(s)")
|
|
124
|
-
if input_types:
|
|
125
|
-
parts.append(f"Input types: {', '.join(input_types)}")
|
|
126
|
-
if placeholders:
|
|
127
|
-
parts.append(f"Input placeholders: {', '.join(placeholders)}")
|
|
128
|
-
if buttons_clean:
|
|
129
|
-
parts.append(f"Buttons: {', '.join(buttons_clean)}")
|
|
130
|
-
|
|
131
|
-
element_summary = " | ".join(parts) if parts else "minimal page content detected"
|
|
132
|
-
return title, " · ".join(nav_links[:5]), element_summary
|
|
133
|
-
|
|
134
|
-
except Exception as exc: # noqa: BLE001
|
|
135
|
-
logger.warning("Page scrape failed for %s: %s", url, exc)
|
|
136
|
-
return "", "", ""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _is_low_signal(elements: str) -> bool:
|
|
140
|
-
"""Return True when the static scrape produced too little signal to generate good goals."""
|
|
141
|
-
return not elements or elements == "minimal page content detected" or len(elements) < 80
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _scrape_page_playwright(url: str, timeout: float = 20.0) -> tuple[str, str, str]:
|
|
145
|
-
"""
|
|
146
|
-
Use headless Playwright to scrape a fully-rendered page.
|
|
147
|
-
|
|
148
|
-
Handles SPAs and JS-heavy apps that httpx can't see — React, Vue,
|
|
149
|
-
Next.js etc. render almost nothing server-side, so static scraping
|
|
150
|
-
returns an empty shell. Playwright runs real JS and captures the
|
|
151
|
-
live DOM after network activity settles.
|
|
152
|
-
|
|
153
|
-
Returns (title, nav, element_summary). Never raises.
|
|
154
|
-
"""
|
|
155
|
-
try:
|
|
156
|
-
from playwright.sync_api import sync_playwright
|
|
157
|
-
|
|
158
|
-
is_docker = os.path.exists("/.dockerenv") or os.getenv("FUSIONTEST_DOCKER") == "1"
|
|
159
|
-
launch_args = (
|
|
160
|
-
["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--disable-setuid-sandbox"]
|
|
161
|
-
if is_docker else []
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
with sync_playwright() as p:
|
|
165
|
-
browser = p.chromium.launch(headless=True, args=launch_args)
|
|
166
|
-
page = browser.new_page(
|
|
167
|
-
user_agent=(
|
|
168
|
-
"Mozilla/5.0 (compatible; FusionTest-Discovery/0.1; "
|
|
169
|
-
"+https://fusiontest.fusionleap.io)"
|
|
170
|
-
),
|
|
171
|
-
)
|
|
172
|
-
page.goto(url, wait_until="networkidle", timeout=int(timeout * 1000))
|
|
173
|
-
|
|
174
|
-
data: dict = page.evaluate("""() => {
|
|
175
|
-
const clean = (t) => (t || '').replace(/\\s+/g, ' ').trim();
|
|
176
|
-
|
|
177
|
-
// Title
|
|
178
|
-
const title = document.title || '';
|
|
179
|
-
|
|
180
|
-
// Nav links from <nav> or role="navigation"
|
|
181
|
-
const navLinks = [
|
|
182
|
-
...document.querySelectorAll('nav a, [role="navigation"] a')
|
|
183
|
-
].map(a => clean(a.textContent))
|
|
184
|
-
.filter(t => t.length > 1 && t.length < 60)
|
|
185
|
-
.slice(0, 15);
|
|
186
|
-
|
|
187
|
-
// Headings
|
|
188
|
-
const headings = [
|
|
189
|
-
...document.querySelectorAll('h1, h2, h3')
|
|
190
|
-
].map(h => clean(h.textContent))
|
|
191
|
-
.filter(t => t.length > 2 && t.length < 120)
|
|
192
|
-
.slice(0, 8);
|
|
193
|
-
|
|
194
|
-
// Visible buttons + ARIA buttons
|
|
195
|
-
const buttons = [
|
|
196
|
-
...document.querySelectorAll(
|
|
197
|
-
'button:not([disabled]), [role="button"], input[type="submit"], a[class*="btn"], a[class*="button"]'
|
|
198
|
-
)
|
|
199
|
-
].map(b => clean(b.textContent || b.value || b.getAttribute('aria-label') || ''))
|
|
200
|
-
.filter(t => t.length > 0 && t.length < 60)
|
|
201
|
-
.filter((t, i, a) => a.indexOf(t) === i) // deduplicate
|
|
202
|
-
.slice(0, 12);
|
|
203
|
-
|
|
204
|
-
// Forms
|
|
205
|
-
const forms = document.querySelectorAll('form').length;
|
|
206
|
-
|
|
207
|
-
// Inputs with labels/placeholders
|
|
208
|
-
const inputs = [
|
|
209
|
-
...document.querySelectorAll('input:not([type="hidden"]), textarea, select')
|
|
210
|
-
].map(i => {
|
|
211
|
-
const id = i.id;
|
|
212
|
-
const label = id ? (document.querySelector(`label[for="${id}"]`) || {}).textContent : '';
|
|
213
|
-
return clean(label || i.placeholder || i.getAttribute('aria-label') || i.name || i.type || '');
|
|
214
|
-
}).filter(t => t.length > 0 && t !== 'text' && t !== 'email')
|
|
215
|
-
.slice(0, 8);
|
|
216
|
-
|
|
217
|
-
// Page sections / landmarks
|
|
218
|
-
const sections = [
|
|
219
|
-
...document.querySelectorAll('[aria-label], [aria-labelledby], section[id], main')
|
|
220
|
-
].map(el => {
|
|
221
|
-
const labelId = el.getAttribute('aria-labelledby');
|
|
222
|
-
if (labelId) {
|
|
223
|
-
const labelEl = document.getElementById(labelId);
|
|
224
|
-
return clean(labelEl ? labelEl.textContent : '');
|
|
225
|
-
}
|
|
226
|
-
return clean(el.getAttribute('aria-label') || el.id || '');
|
|
227
|
-
}).filter(t => t.length > 2 && t.length < 80)
|
|
228
|
-
.filter((t, i, a) => a.indexOf(t) === i)
|
|
229
|
-
.slice(0, 6);
|
|
230
|
-
|
|
231
|
-
return { title, navLinks, headings, buttons, forms, inputs, sections };
|
|
232
|
-
}""")
|
|
233
|
-
browser.close()
|
|
234
|
-
|
|
235
|
-
title: str = data.get("title", "")
|
|
236
|
-
nav_links: list = data.get("navLinks", [])
|
|
237
|
-
nav = " · ".join(nav_links[:5])
|
|
238
|
-
|
|
239
|
-
parts: list[str] = []
|
|
240
|
-
if data.get("headings"):
|
|
241
|
-
parts.append(f"Headings: {', '.join(data['headings'])}")
|
|
242
|
-
if nav_links:
|
|
243
|
-
parts.append(f"Navigation: {', '.join(nav_links)}")
|
|
244
|
-
if data.get("sections"):
|
|
245
|
-
parts.append(f"Sections: {', '.join(data['sections'])}")
|
|
246
|
-
if data.get("forms"):
|
|
247
|
-
parts.append(f"{data['forms']} form(s)")
|
|
248
|
-
if data.get("inputs"):
|
|
249
|
-
parts.append(f"Inputs: {', '.join(data['inputs'])}")
|
|
250
|
-
if data.get("buttons"):
|
|
251
|
-
parts.append(f"Buttons: {', '.join(data['buttons'])}")
|
|
252
|
-
|
|
253
|
-
elements = " | ".join(parts) if parts else "minimal page content detected"
|
|
254
|
-
logger.info("Playwright scrape of %s — %d chars of UI signal", url, len(elements))
|
|
255
|
-
return title, nav, elements
|
|
256
|
-
|
|
257
|
-
except Exception as exc: # noqa: BLE001
|
|
258
|
-
logger.warning("Playwright scrape failed for %s: %s", url, exc)
|
|
259
|
-
return "", "", ""
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
# ── Shared prompt ─────────────────────────────────────────────────────────────
|
|
263
|
-
|
|
264
|
-
_SYSTEM_PROMPT = """\
|
|
265
|
-
You are a senior QA engineer specialising in automated UI testing.
|
|
266
|
-
Generate concise, executable natural-language test goals for a specific web application
|
|
267
|
-
based on its URL, title, navigation structure, and visible interactive elements.
|
|
268
|
-
|
|
269
|
-
CRITICAL RULE — Always include authentication goals:
|
|
270
|
-
Most web apps have login and signup pages even if they are not visible on the
|
|
271
|
-
scraped URL. Unless the site is clearly a static/marketing-only page with no
|
|
272
|
-
accounts (e.g. a simple brochure site), you MUST include at least two
|
|
273
|
-
critical_path goals covering:
|
|
274
|
-
1. Login with valid credentials and verify the main dashboard or home page loads.
|
|
275
|
-
2. Attempt login with invalid credentials and verify an error message is shown.
|
|
276
|
-
If there is any evidence of accounts (nav links like "Login", "Sign In", "Sign Up",
|
|
277
|
-
"My Account", "Dashboard"; or the URL itself looks like an app), add these goals
|
|
278
|
-
with high confidence even if the login form is not visible on this specific page.
|
|
279
|
-
|
|
280
|
-
Other rules:
|
|
281
|
-
- Write each goal as a complete action sentence a tester would follow,
|
|
282
|
-
e.g. "Navigate to the Pricing page and verify all three plan tiers are displayed".
|
|
283
|
-
- Each goal must be self-contained and testable in isolation.
|
|
284
|
-
- Goals must be SPECIFIC to this application — reference real features, pages,
|
|
285
|
-
and visible flows. Do not invent features that clearly don't exist.
|
|
286
|
-
- Do NOT reference CSS selectors, element IDs, or code.
|
|
287
|
-
- Cover a range of flows: auth, navigation, forms, core features, and error states.
|
|
288
|
-
- Return ONLY valid JSON: a flat array of objects with these exact keys:
|
|
289
|
-
name (string — short 5-8 word title, e.g. "Login with valid credentials")
|
|
290
|
-
text (string — full executable goal sentence)
|
|
291
|
-
category ("critical_path" | "happy_path" | "edge_cases")
|
|
292
|
-
confidence (number 0.0–1.0)
|
|
293
|
-
- Do not add any explanation, markdown fences, or extra keys.
|
|
294
|
-
"""
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def _build_user_message(url: str, title: str, nav: str, elements: str, max_goals: int) -> str:
|
|
298
|
-
# Hint that login/signup sub-pages likely exist even if not scraped.
|
|
299
|
-
auth_hint = (
|
|
300
|
-
"\nNote: if this appears to be a web app with user accounts, include login "
|
|
301
|
-
"and signup goals — the auth pages may be at /login, /signin, or /signup "
|
|
302
|
-
"even if not visible in the scraped elements above."
|
|
303
|
-
)
|
|
304
|
-
return (
|
|
305
|
-
f"URL: {url}\n"
|
|
306
|
-
f"Page title: {title or '(unknown)'}\n"
|
|
307
|
-
f"Navigation: {nav or '(not detected)'}\n"
|
|
308
|
-
f"Page elements: {elements or '(could not fetch page)'}\n"
|
|
309
|
-
f"{auth_hint}\n\n"
|
|
310
|
-
f"Generate up to {max_goals} test goals specific to this site, "
|
|
311
|
-
f"distributed across critical_path (auth + core flows), "
|
|
312
|
-
f"happy_path (navigation + features), and edge_cases (errors + validation)."
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _derive_name(text: str) -> str:
|
|
317
|
-
"""Fallback: condense a goal sentence into a short 5-8 word title."""
|
|
318
|
-
# Strip leading verb phrases like "Verify that", "Navigate to", "Check that"
|
|
319
|
-
cleaned = re.sub(
|
|
320
|
-
r"^(verify\s+(that\s+)?|navigate\s+to\s+(the\s+)?|check\s+(that\s+)?|"
|
|
321
|
-
r"attempt\s+to\s+|click\s+|submit\s+|open\s+(the\s+)?|ensure\s+(that\s+)?)",
|
|
322
|
-
"",
|
|
323
|
-
text,
|
|
324
|
-
flags=re.I,
|
|
325
|
-
).strip()
|
|
326
|
-
words = cleaned.split()
|
|
327
|
-
name = " ".join(words[:7])
|
|
328
|
-
if len(words) > 7:
|
|
329
|
-
name += "…"
|
|
330
|
-
return name.capitalize()
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def _parse_goals(raw: str, max_goals: int) -> list[DiscoveredGoal]:
|
|
334
|
-
"""Parse the LLM JSON response into DiscoveredGoal objects."""
|
|
335
|
-
# Strip markdown fences if the model included them
|
|
336
|
-
raw = re.sub(r"^```[a-z]*\n?", "", raw.strip(), flags=re.M)
|
|
337
|
-
raw = re.sub(r"\n?```$", "", raw.strip(), flags=re.M)
|
|
338
|
-
|
|
339
|
-
data = json.loads(raw)
|
|
340
|
-
items = data if isinstance(data, list) else data.get("goals", data.get("items", []))
|
|
341
|
-
|
|
342
|
-
goals: list[DiscoveredGoal] = []
|
|
343
|
-
for i, item in enumerate(items[:max_goals]):
|
|
344
|
-
cat = item.get("category", "happy_path")
|
|
345
|
-
if cat not in ("critical_path", "happy_path", "edge_cases"):
|
|
346
|
-
cat = "happy_path"
|
|
347
|
-
conf = float(item.get("confidence", 0.8))
|
|
348
|
-
conf = max(0.0, min(1.0, conf))
|
|
349
|
-
text = item.get("text", "").strip()
|
|
350
|
-
if not text:
|
|
351
|
-
continue
|
|
352
|
-
name = item.get("name", "").strip() or _derive_name(text)
|
|
353
|
-
goals.append(
|
|
354
|
-
DiscoveredGoal(
|
|
355
|
-
id=f"goal-{i:03d}",
|
|
356
|
-
name=name,
|
|
357
|
-
text=text,
|
|
358
|
-
category=cat, # type: ignore[arg-type]
|
|
359
|
-
confidence=conf,
|
|
360
|
-
selected=conf >= 0.75,
|
|
361
|
-
)
|
|
362
|
-
)
|
|
363
|
-
return goals
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
# ── Claude (Anthropic) ────────────────────────────────────────────────────────
|
|
367
|
-
|
|
368
|
-
# Module-level cache so we only call models.list() once per process
|
|
369
|
-
_claude_model_cache: str | None = None
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def _resolve_claude_model(client: object, preferred: str) -> str | None:
|
|
373
|
-
"""
|
|
374
|
-
Return the best available Claude model for this API key.
|
|
375
|
-
Prefers 'sonnet' > 'haiku' > 'opus' in the latest available generation.
|
|
376
|
-
Result is cached for the lifetime of the process.
|
|
377
|
-
"""
|
|
378
|
-
global _claude_model_cache # noqa: PLW0603
|
|
379
|
-
if _claude_model_cache is not None:
|
|
380
|
-
return _claude_model_cache
|
|
381
|
-
|
|
382
|
-
# If caller explicitly set a model ID, trust it (skip auto-detect)
|
|
383
|
-
if preferred:
|
|
384
|
-
_claude_model_cache = preferred
|
|
385
|
-
return _claude_model_cache
|
|
386
|
-
|
|
387
|
-
try:
|
|
388
|
-
models_page = client.models.list()
|
|
389
|
-
ids = [m.id for m in models_page.data]
|
|
390
|
-
|
|
391
|
-
# Rank by preference: sonnet > haiku > opus, newer date wins within tier
|
|
392
|
-
def _rank(model_id: str) -> tuple[int, str]:
|
|
393
|
-
mid = model_id.lower()
|
|
394
|
-
tier = 1 if "sonnet" in mid else (2 if "haiku" in mid else 3)
|
|
395
|
-
return (tier, model_id) # lexicographic date sort is fine for YYYYMMDD suffixes
|
|
396
|
-
|
|
397
|
-
ids.sort(key=_rank)
|
|
398
|
-
_claude_model_cache = ids[0] if ids else None
|
|
399
|
-
logger.info("Auto-selected Claude model: %s (from %d available)", _claude_model_cache, len(ids))
|
|
400
|
-
return _claude_model_cache
|
|
401
|
-
|
|
402
|
-
except Exception as exc: # noqa: BLE001
|
|
403
|
-
logger.warning("Could not list Claude models: %s", exc)
|
|
404
|
-
return None
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def _claude_goals(
|
|
408
|
-
url: str, title: str, nav: str, elements: str, cfg: GoalGeneratorConfig,
|
|
409
|
-
) -> list[DiscoveredGoal]:
|
|
410
|
-
"""Call Claude to generate goals using the best available model. Returns [] on any error."""
|
|
411
|
-
api_key = os.getenv("ANTHROPIC_API_KEY", "")
|
|
412
|
-
if not api_key:
|
|
413
|
-
return []
|
|
414
|
-
|
|
415
|
-
try:
|
|
416
|
-
import anthropic # lazy import
|
|
417
|
-
|
|
418
|
-
client = anthropic.Anthropic(api_key=api_key)
|
|
419
|
-
model = _resolve_claude_model(client, cfg.claude_model)
|
|
420
|
-
if not model:
|
|
421
|
-
logger.warning("No Claude models available for this API key")
|
|
422
|
-
return []
|
|
423
|
-
|
|
424
|
-
user_msg = _build_user_message(url, title, nav, elements, cfg.max_goals)
|
|
425
|
-
resp = client.messages.create(
|
|
426
|
-
model=model,
|
|
427
|
-
max_tokens=2048,
|
|
428
|
-
temperature=cfg.temperature,
|
|
429
|
-
system=_SYSTEM_PROMPT,
|
|
430
|
-
messages=[{"role": "user", "content": user_msg}],
|
|
431
|
-
)
|
|
432
|
-
raw = resp.content[0].text if resp.content else "[]"
|
|
433
|
-
return _parse_goals(raw, cfg.max_goals)
|
|
434
|
-
|
|
435
|
-
except Exception as exc: # noqa: BLE001
|
|
436
|
-
logger.warning("Claude goal generation failed: %s", exc)
|
|
437
|
-
return []
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
# ── OpenAI ────────────────────────────────────────────────────────────────────
|
|
441
|
-
|
|
442
|
-
def _openai_goals(
|
|
443
|
-
url: str, title: str, nav: str, elements: str, cfg: GoalGeneratorConfig,
|
|
444
|
-
) -> list[DiscoveredGoal]:
|
|
445
|
-
"""Call OpenAI to generate goals. Returns [] on any error."""
|
|
446
|
-
api_key = os.getenv("OPENAI_API_KEY", "")
|
|
447
|
-
if not api_key:
|
|
448
|
-
return []
|
|
449
|
-
|
|
450
|
-
try:
|
|
451
|
-
from openai import OpenAI # lazy import
|
|
452
|
-
|
|
453
|
-
client = OpenAI(api_key=api_key)
|
|
454
|
-
user_msg = _build_user_message(url, title, nav, elements, cfg.max_goals)
|
|
455
|
-
|
|
456
|
-
resp = client.chat.completions.create(
|
|
457
|
-
model=cfg.openai_model,
|
|
458
|
-
temperature=cfg.temperature,
|
|
459
|
-
messages=[
|
|
460
|
-
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
461
|
-
{"role": "user", "content": user_msg},
|
|
462
|
-
],
|
|
463
|
-
response_format={"type": "json_object"},
|
|
464
|
-
)
|
|
465
|
-
|
|
466
|
-
raw = resp.choices[0].message.content or "[]"
|
|
467
|
-
return _parse_goals(raw, cfg.max_goals)
|
|
468
|
-
|
|
469
|
-
except Exception as exc: # noqa: BLE001
|
|
470
|
-
logger.warning("OpenAI goal generation failed: %s", exc)
|
|
471
|
-
return []
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
# ── Gemini ────────────────────────────────────────────────────────────────────
|
|
475
|
-
|
|
476
|
-
def _gemini_goals(
|
|
477
|
-
url: str, title: str, nav: str, elements: str, cfg: GoalGeneratorConfig,
|
|
478
|
-
) -> list[DiscoveredGoal]:
|
|
479
|
-
"""Call Gemini Flash to generate goals. Returns [] on any error."""
|
|
480
|
-
api_key = os.getenv("GEMINI_API_KEY", "")
|
|
481
|
-
if not api_key:
|
|
482
|
-
return []
|
|
483
|
-
|
|
484
|
-
try:
|
|
485
|
-
from google import genai # lazy import # noqa: I001
|
|
486
|
-
from google.genai import types as genai_types
|
|
487
|
-
|
|
488
|
-
client = genai.Client(api_key=api_key)
|
|
489
|
-
model_name = default_model_for_backend("gemini")
|
|
490
|
-
user_msg = _build_user_message(url, title, nav, elements, cfg.max_goals)
|
|
491
|
-
resp = client.models.generate_content(
|
|
492
|
-
model=model_name,
|
|
493
|
-
contents=user_msg,
|
|
494
|
-
config=genai_types.GenerateContentConfig(
|
|
495
|
-
system_instruction=_SYSTEM_PROMPT,
|
|
496
|
-
temperature=cfg.temperature,
|
|
497
|
-
max_output_tokens=2048,
|
|
498
|
-
),
|
|
499
|
-
)
|
|
500
|
-
raw = resp.text or "[]"
|
|
501
|
-
return _parse_goals(raw, cfg.max_goals)
|
|
502
|
-
|
|
503
|
-
except Exception as exc: # noqa: BLE001
|
|
504
|
-
logger.warning("Gemini goal generation failed: %s", exc)
|
|
505
|
-
return []
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
# ── Groq (free cloud inference) ───────────────────────────────────────────────
|
|
509
|
-
|
|
510
|
-
def _groq_goals(
|
|
511
|
-
url: str, title: str, nav: str, elements: str, cfg: GoalGeneratorConfig,
|
|
512
|
-
) -> list[DiscoveredGoal]:
|
|
513
|
-
"""Call Groq's free cloud inference to generate goals. Returns [] on any error."""
|
|
514
|
-
api_key = os.getenv("GROQ_API_KEY", "")
|
|
515
|
-
if not api_key:
|
|
516
|
-
return []
|
|
517
|
-
|
|
518
|
-
try:
|
|
519
|
-
from openai import OpenAI # Groq uses the OpenAI-compatible API
|
|
520
|
-
|
|
521
|
-
model = default_model_for_backend("groq")
|
|
522
|
-
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=api_key)
|
|
523
|
-
user_msg = _build_user_message(url, title, nav, elements, cfg.max_goals)
|
|
524
|
-
resp = client.chat.completions.create(
|
|
525
|
-
model=model,
|
|
526
|
-
temperature=cfg.temperature,
|
|
527
|
-
messages=[
|
|
528
|
-
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
529
|
-
{"role": "user", "content": user_msg},
|
|
530
|
-
],
|
|
531
|
-
)
|
|
532
|
-
raw = resp.choices[0].message.content or "[]"
|
|
533
|
-
goals = _parse_goals(raw, cfg.max_goals)
|
|
534
|
-
if goals:
|
|
535
|
-
logger.info("Groq (%s) generated %d goals for %s", model, len(goals), url)
|
|
536
|
-
return goals
|
|
537
|
-
|
|
538
|
-
except Exception as exc: # noqa: BLE001
|
|
539
|
-
logger.warning("Groq goal generation failed: %s", exc)
|
|
540
|
-
return []
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
# ── Ollama (local, free) ──────────────────────────────────────────────────────
|
|
544
|
-
|
|
545
|
-
def _ollama_goals(
|
|
546
|
-
url: str, title: str, nav: str, elements: str, cfg: GoalGeneratorConfig,
|
|
547
|
-
) -> list[DiscoveredGoal]:
|
|
548
|
-
"""Call a local Ollama model to generate goals. Returns [] if Ollama isn't running."""
|
|
549
|
-
try:
|
|
550
|
-
from openai import OpenAI # reuse openai SDK with Ollama's compatible API
|
|
551
|
-
|
|
552
|
-
host = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
553
|
-
model = os.getenv("OLLAMA_MODEL", "qwen2.5:7b")
|
|
554
|
-
client = OpenAI(base_url=f"{host}/v1", api_key="ollama")
|
|
555
|
-
|
|
556
|
-
user_msg = _build_user_message(url, title, nav, elements, cfg.max_goals)
|
|
557
|
-
resp = client.chat.completions.create(
|
|
558
|
-
model=model,
|
|
559
|
-
temperature=cfg.temperature,
|
|
560
|
-
messages=[
|
|
561
|
-
{"role": "system", "content": _SYSTEM_PROMPT},
|
|
562
|
-
{"role": "user", "content": user_msg},
|
|
563
|
-
],
|
|
564
|
-
)
|
|
565
|
-
raw = resp.choices[0].message.content or "[]"
|
|
566
|
-
goals = _parse_goals(raw, cfg.max_goals)
|
|
567
|
-
if goals:
|
|
568
|
-
logger.info("Ollama (%s) generated %d goals for %s", model, len(goals), url)
|
|
569
|
-
return goals
|
|
570
|
-
|
|
571
|
-
except Exception as exc: # noqa: BLE001
|
|
572
|
-
logger.debug("Ollama goal generation skipped: %s", exc)
|
|
573
|
-
return []
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
# ── Heuristic fallback ────────────────────────────────────────────────────────
|
|
577
|
-
|
|
578
|
-
_HEURISTIC_TEMPLATES: dict[GoalCategory, list[tuple[str, str, float]]] = {
|
|
579
|
-
"critical_path": [
|
|
580
|
-
("Account signup flow works", "Sign up for a new account using a valid email and password", 0.95),
|
|
581
|
-
("Login with valid credentials", "Log in with valid credentials and verify the main page loads", 0.95),
|
|
582
|
-
("Logout clears session", "Log out and confirm the session is cleared", 0.90),
|
|
583
|
-
("Password reset flow works", "Reset password via the forgot-password flow", 0.85),
|
|
584
|
-
("Primary conversion flow completes", "Complete the primary conversion flow (e.g. purchase, subscribe, or submit)", 0.80),
|
|
585
|
-
],
|
|
586
|
-
"happy_path": [
|
|
587
|
-
("Main navigation links work", "Navigate to each top-level section via the main navigation menu", 0.90),
|
|
588
|
-
("Contact form submits successfully", "Fill in and submit the main contact or inquiry form successfully", 0.80),
|
|
589
|
-
("Search returns relevant results", "Search for a term and verify relevant results appear", 0.75),
|
|
590
|
-
("Profile details can be saved", "Edit account profile details and save changes", 0.75),
|
|
591
|
-
("Paginated content loads correctly", "Browse paginated content and navigate to the next page", 0.70),
|
|
592
|
-
("File upload works as expected", "Upload or attach a file where the UI allows it", 0.65),
|
|
593
|
-
("Filters and sorting update list", "Apply a filter or sort option and verify the list updates", 0.70),
|
|
594
|
-
],
|
|
595
|
-
"edge_cases": [
|
|
596
|
-
("Invalid login shows error message", "Attempt to log in with an incorrect password and verify the error message", 0.90),
|
|
597
|
-
("Duplicate email rejected on signup", "Submit the registration form with an already-used email address", 0.85),
|
|
598
|
-
("Empty form shows validation errors", "Submit a required form with all fields empty and verify validation errors", 0.85),
|
|
599
|
-
("Long input handled gracefully", "Enter an extremely long string into a text input and verify it is handled gracefully", 0.70),
|
|
600
|
-
("404 page shown for bad URL", "Navigate to a non-existent URL and verify a 404 or error page is shown", 0.80),
|
|
601
|
-
("Protected page redirects unauthenticated", "Attempt to access a protected page while logged out", 0.80),
|
|
602
|
-
("Invalid email format shows error", "Enter invalid characters in an email field and verify the error state", 0.75),
|
|
603
|
-
("Back button keeps app state consistent", "Click the back button mid-flow and verify the app state is consistent", 0.65),
|
|
604
|
-
],
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
def _heuristic_goals(max_goals: int) -> list[DiscoveredGoal]:
|
|
609
|
-
goals: list[DiscoveredGoal] = []
|
|
610
|
-
idx = 0
|
|
611
|
-
for category, templates in _HEURISTIC_TEMPLATES.items():
|
|
612
|
-
for name, text, confidence in templates:
|
|
613
|
-
if len(goals) >= max_goals:
|
|
614
|
-
break
|
|
615
|
-
goals.append(
|
|
616
|
-
DiscoveredGoal(
|
|
617
|
-
id=f"goal-{idx:03d}",
|
|
618
|
-
name=name,
|
|
619
|
-
text=text,
|
|
620
|
-
category=category, # type: ignore[arg-type]
|
|
621
|
-
confidence=confidence,
|
|
622
|
-
selected=confidence >= 0.80,
|
|
623
|
-
)
|
|
624
|
-
)
|
|
625
|
-
idx += 1
|
|
626
|
-
return goals[:max_goals]
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
# ── Public interface ──────────────────────────────────────────────────────────
|
|
630
|
-
|
|
631
|
-
class GoalGenerator:
|
|
632
|
-
"""Generates prioritised, site-specific test goals for a given URL."""
|
|
633
|
-
|
|
634
|
-
def __init__(self, config: GoalGeneratorConfig | None = None) -> None:
|
|
635
|
-
self.config = config or GoalGeneratorConfig()
|
|
636
|
-
# Set after generate() — records which backend actually produced the goals.
|
|
637
|
-
self.last_backend_used: str = "heuristic"
|
|
638
|
-
|
|
639
|
-
def generate(self, url: str, preferred_backend: str | None = None) -> list[DiscoveredGoal]:
|
|
640
|
-
"""
|
|
641
|
-
1. Scrape the URL for real page structure
|
|
642
|
-
2. Try LLMs — preferred_backend goes first, then the default order
|
|
643
|
-
3. Fall back to heuristic templates if no LLM is available
|
|
644
|
-
|
|
645
|
-
After this call, self.last_backend_used reflects which backend
|
|
646
|
-
actually generated the goals (not just which key is present).
|
|
647
|
-
"""
|
|
648
|
-
title, nav, elements = _scrape_page_text(url)
|
|
649
|
-
|
|
650
|
-
if _is_low_signal(elements):
|
|
651
|
-
# SPA or JS-heavy app — static HTML has almost nothing.
|
|
652
|
-
# Fall back to a real headless browser to get the rendered DOM.
|
|
653
|
-
logger.info(
|
|
654
|
-
"Static scrape low-signal for %s (%d chars) — trying Playwright",
|
|
655
|
-
url, len(elements),
|
|
656
|
-
)
|
|
657
|
-
pw_title, pw_nav, pw_elements = _scrape_page_playwright(url)
|
|
658
|
-
if not _is_low_signal(pw_elements):
|
|
659
|
-
title = pw_title or title
|
|
660
|
-
nav = pw_nav or nav
|
|
661
|
-
elements = pw_elements
|
|
662
|
-
logger.info("Playwright scrape improved signal to %d chars", len(elements))
|
|
663
|
-
else:
|
|
664
|
-
logger.warning("Playwright scrape also low-signal for %s — LLM will use URL only", url)
|
|
665
|
-
|
|
666
|
-
if title or elements:
|
|
667
|
-
logger.info("Final scrape signal — title=%r elements=%d chars", title[:40] if title else "", len(elements))
|
|
668
|
-
else:
|
|
669
|
-
logger.warning("No scrape signal for %s — LLM will use URL only", url)
|
|
670
|
-
|
|
671
|
-
_ALL_BACKENDS: list[tuple[str, object]] = [
|
|
672
|
-
("groq", _groq_goals),
|
|
673
|
-
("claude", _claude_goals),
|
|
674
|
-
("openai", _openai_goals),
|
|
675
|
-
("gemini", _gemini_goals),
|
|
676
|
-
("ollama", _ollama_goals),
|
|
677
|
-
]
|
|
678
|
-
|
|
679
|
-
# Put the user-preferred backend first so it wins when multiple keys exist.
|
|
680
|
-
preferred = (preferred_backend or "").strip().lower()
|
|
681
|
-
ordered = (
|
|
682
|
-
sorted(_ALL_BACKENDS, key=lambda t: 0 if t[0] == preferred else 1)
|
|
683
|
-
if preferred and preferred != "auto"
|
|
684
|
-
else _ALL_BACKENDS
|
|
685
|
-
)
|
|
686
|
-
|
|
687
|
-
for name, fn in ordered:
|
|
688
|
-
goals = fn(url, title, nav, elements, self.config) # type: ignore[operator]
|
|
689
|
-
if goals:
|
|
690
|
-
self.last_backend_used = name
|
|
691
|
-
return goals
|
|
692
|
-
|
|
693
|
-
logger.info("No LLM available — using heuristic fallback for %s", url)
|
|
694
|
-
self.last_backend_used = "heuristic"
|
|
695
|
-
return _heuristic_goals(self.config.max_goals)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|