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.
Files changed (41) hide show
  1. {fusiontest-0.2.1 → fusiontest-0.2.2}/PKG-INFO +1 -1
  2. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/action_model.py +9 -4
  3. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/playwright_adapter.py +25 -0
  4. fusiontest-0.2.2/fusiontest/discovery/goal_generator.py +287 -0
  5. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/PKG-INFO +1 -1
  6. {fusiontest-0.2.1 → fusiontest-0.2.2}/pyproject.toml +1 -1
  7. fusiontest-0.2.1/fusiontest/discovery/goal_generator.py +0 -695
  8. {fusiontest-0.2.1 → fusiontest-0.2.2}/README.md +0 -0
  9. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/__init__.py +0 -0
  10. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/cli.py +0 -0
  11. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/__init__.py +0 -0
  12. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/goal_verifier.py +0 -0
  13. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/replay.py +0 -0
  14. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/runner.py +0 -0
  15. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/screen_parser.py +0 -0
  16. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/secrets.py +0 -0
  17. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/core/tokens.py +0 -0
  18. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/__init__.py +0 -0
  19. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/macos_adapter.py +0 -0
  20. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/desktop/windows_adapter.py +0 -0
  21. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/discovery/__init__.py +0 -0
  22. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/guardrails/__init__.py +0 -0
  23. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/guardrails/engine.py +0 -0
  24. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/__init__.py +0 -0
  25. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/android_adapter.py +0 -0
  26. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/mobile/ios_adapter.py +0 -0
  27. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/recording/__init__.py +0 -0
  28. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/recording/recorder.py +0 -0
  29. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/reporting/__init__.py +0 -0
  30. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/reporting/reporter.py +0 -0
  31. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/__init__.py +0 -0
  32. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/data_collector.py +0 -0
  33. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/dataset_builder.py +0 -0
  34. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest/training/trainer.py +0 -0
  35. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/SOURCES.txt +0 -0
  36. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/dependency_links.txt +0 -0
  37. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/entry_points.txt +0 -0
  38. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/not-zip-safe +0 -0
  39. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/requires.txt +0 -0
  40. {fusiontest-0.2.1 → fusiontest-0.2.2}/fusiontest.egg-info/top_level.txt +0 -0
  41. {fusiontest-0.2.1 → fusiontest-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: AI-powered UI testing for mobile and desktop — by FusionLeap.io
5
5
  Author-email: FusionLeap <hello@fusionleap.io>
6
6
  License: MIT
@@ -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
- return self._call_llm_single(prompt, backend, model, request)
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=LLM_MAX_OUTPUT_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=LLM_MAX_OUTPUT_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=LLM_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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: AI-powered UI testing for mobile and desktop — by FusionLeap.io
5
5
  Author-email: FusionLeap <hello@fusionleap.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fusiontest"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "AI-powered UI testing for mobile and desktop — by FusionLeap.io"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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