fusiontest 0.2.4__tar.gz → 0.2.6__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 (40) hide show
  1. {fusiontest-0.2.4 → fusiontest-0.2.6}/PKG-INFO +1 -1
  2. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/action_model.py +25 -0
  3. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/runner.py +51 -10
  4. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/playwright_adapter.py +43 -0
  5. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/PKG-INFO +1 -1
  6. {fusiontest-0.2.4 → fusiontest-0.2.6}/pyproject.toml +1 -1
  7. {fusiontest-0.2.4 → fusiontest-0.2.6}/README.md +0 -0
  8. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/__init__.py +0 -0
  9. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/cli.py +0 -0
  10. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/__init__.py +0 -0
  11. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/goal_verifier.py +0 -0
  12. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/replay.py +0 -0
  13. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/screen_parser.py +0 -0
  14. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/secrets.py +0 -0
  15. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/tokens.py +0 -0
  16. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/__init__.py +0 -0
  17. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/macos_adapter.py +0 -0
  18. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/windows_adapter.py +0 -0
  19. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/discovery/__init__.py +0 -0
  20. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/discovery/goal_generator.py +0 -0
  21. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/guardrails/__init__.py +0 -0
  22. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/guardrails/engine.py +0 -0
  23. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/__init__.py +0 -0
  24. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/android_adapter.py +0 -0
  25. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/ios_adapter.py +0 -0
  26. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/recording/__init__.py +0 -0
  27. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/recording/recorder.py +0 -0
  28. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/reporting/__init__.py +0 -0
  29. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/reporting/reporter.py +0 -0
  30. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/__init__.py +0 -0
  31. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/data_collector.py +0 -0
  32. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/dataset_builder.py +0 -0
  33. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/trainer.py +0 -0
  34. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/SOURCES.txt +0 -0
  35. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/dependency_links.txt +0 -0
  36. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/entry_points.txt +0 -0
  37. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/not-zip-safe +0 -0
  38. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/requires.txt +0 -0
  39. {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/top_level.txt +0 -0
  40. {fusiontest-0.2.4 → fusiontest-0.2.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.4
3
+ Version: 0.2.6
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
@@ -87,6 +87,23 @@ def _is_backend_specific_error(err: Exception) -> bool:
87
87
  return any(tok in msg for tok in _BACKEND_SPECIFIC_TOKENS)
88
88
 
89
89
 
90
+ # A single request bigger than this backend accepts: context window, or a
91
+ # per-minute token allowance smaller than the request itself (Groq free tier:
92
+ # 8,000 TPM; a goal-discovery prompt can be ~9k). Waiting won't help, but the
93
+ # backend is fine for smaller requests — so fall through without a cooldown.
94
+ _TOO_LARGE_TOKENS = (
95
+ "request too large", "reduce your message size", "context_length_exceeded",
96
+ "maximum context length", "prompt is too long", "input token count",
97
+ )
98
+
99
+
100
+ def _is_request_too_large(err: Exception) -> bool:
101
+ if _error_status_code(err) == 413:
102
+ return True
103
+ msg = str(err).lower()
104
+ return any(tok in msg for tok in _TOO_LARGE_TOKENS)
105
+
106
+
90
107
  def _is_rate_limit(err: Exception) -> bool:
91
108
  if err.__class__.__name__ == "RateLimitError":
92
109
  return True
@@ -536,6 +553,8 @@ class LLMCaller:
536
553
  then fall through.
537
554
  - On backend-specific errors (retired model, bad/missing key for
538
555
  that provider): start a 5-minute cooldown and fall through.
556
+ - On a request too large for that backend (413, context length):
557
+ fall through without a cooldown — smaller requests still fit.
539
558
  - On other non-transient errors (bad request): raise immediately.
540
559
 
541
560
  Raises LLMUnavailableError when no entry could answer.
@@ -561,6 +580,12 @@ class LLMCaller:
561
580
  return answer
562
581
  except Exception as err: # noqa: BLE001
563
582
  last_error = err
583
+ if _is_request_too_large(err):
584
+ logger.warning(
585
+ "%s:%s can't take a request this size (%s) — falling through.",
586
+ backend, model or "auto", err.__class__.__name__,
587
+ )
588
+ break
564
589
  transient = _is_transient_error(err)
565
590
  rate_limited = _is_rate_limit(err)
566
591
 
@@ -179,6 +179,10 @@ class RunnerConfig:
179
179
  # are replayed without LLM calls; the LLM re-verifies every N replays.
180
180
  trace_store: TraceStore | None = None
181
181
  reverify_every: int = 10
182
+ # Stop a goal once the screen hasn't changed for this many consecutive
183
+ # steps (actions with no visible effect, or no usable action) instead of
184
+ # spending LLM calls until max_steps_per_goal.
185
+ max_stalled_steps: int = 3
182
186
 
183
187
 
184
188
  # ── Runner ─────────────────────────────────────────────────────────────────────
@@ -437,6 +441,9 @@ class FusionTestRunner:
437
441
 
438
442
  self._navigate(navigate_url)
439
443
 
444
+ prev_screen: str | None = None # screen at the previous step
445
+ stalled = 0 # consecutive steps with an unchanged screen
446
+
440
447
  for step_num in range(1, self.config.max_steps_per_goal + 1):
441
448
  step.steps_taken = step_num
442
449
 
@@ -454,6 +461,23 @@ class FusionTestRunner:
454
461
  screen_text = _redact(screen_text)
455
462
  _drop_if_no_effect(screen_text)
456
463
 
464
+ # No progress: tell the model its last action did nothing, and stop
465
+ # the goal once nothing has changed for max_stalled_steps steps.
466
+ if prev_screen is not None and screen_text == prev_screen:
467
+ stalled += 1
468
+ if history and not history[-1].endswith(_NO_EFFECT_NOTE):
469
+ history[-1] += _NO_EFFECT_NOTE
470
+ else:
471
+ stalled = 0
472
+ prev_screen = screen_text
473
+ if stalled >= self.config.max_stalled_steps:
474
+ logger.info(f" No progress for {stalled} steps — stopping this goal")
475
+ self._last_chance_verify(
476
+ step, goal, screen_text, history, recorded,
477
+ reason=f"no_progress: the screen didn't change for {stalled} consecutive steps",
478
+ )
479
+ break
480
+
457
481
  # NOTE: We record the screen hash AFTER the model decides, not
458
482
  # before. This prevents observation/verification goals from
459
483
  # triggering screen loop detection when the page doesn't change.
@@ -609,16 +633,10 @@ class FusionTestRunner:
609
633
  except Exception:
610
634
  final_screen_text = screen_text # fall back to last known screen
611
635
 
612
- step.error = f"max_steps_exceeded ({self.config.max_steps_per_goal})"
613
- if self.verifier.is_complete(goal, final_screen_text, history):
614
- step.success = True
615
- step.error = ""
616
- self._last_recording = self._make_recording(recorded, final_screen_text)
617
- logger.info(
618
- f" Goal verified complete at max_steps "
619
- f"({self.config.max_steps_per_goal}) — last-chance check passed"
620
- )
621
- self._add_verifier_tokens(step)
636
+ self._last_chance_verify(
637
+ step, goal, final_screen_text, history, recorded,
638
+ reason=f"max_steps_exceeded ({self.config.max_steps_per_goal})",
639
+ )
622
640
 
623
641
  if not step.success and self.config.screenshot_on_failure:
624
642
  try:
@@ -755,6 +773,26 @@ class FusionTestRunner:
755
773
  )
756
774
  return replace(action, value=resolver.resolve(action.value)), None
757
775
 
776
+ def _last_chance_verify(
777
+ self, step: StepResult, goal: str, screen_text: str, history: list[str],
778
+ recorded: list[RecordedAction], reason: str,
779
+ ) -> None:
780
+ """The goal is ending without the model saying "done" (step limit or no
781
+ progress): ask the verifier once whether it's met anyway. Otherwise the
782
+ goal fails with `reason` — or errors, if the verifier couldn't answer."""
783
+ step.error = reason
784
+ if self.verifier.is_complete(goal, screen_text, history):
785
+ step.success = True
786
+ step.error = ""
787
+ self._last_recording = self._make_recording(recorded, screen_text)
788
+ logger.info(f" Goal verified complete on last-chance check ({reason})")
789
+ else:
790
+ failure = getattr(self.verifier, "last_failure_detail", None)
791
+ if isinstance(failure, str):
792
+ step.infra_error = True
793
+ step.error = f"verifier_unavailable: verifier: {failure}"[:2000]
794
+ self._add_verifier_tokens(step)
795
+
758
796
  def _add_verifier_tokens(self, step: StepResult) -> None:
759
797
  usage = getattr(self.verifier, "last_token_usage", None)
760
798
  if isinstance(usage, TokenUsage):
@@ -789,6 +827,9 @@ class FusionTestRunner:
789
827
 
790
828
  # ── Secret binding helpers ─────────────────────────────────────────────────────
791
829
 
830
+ # Appended to the model's history entry for an action that changed nothing.
831
+ _NO_EFFECT_NOTE = " → no visible change on screen"
832
+
792
833
  # Recorded actions that are dropped from a trace when they didn't change the screen.
793
834
  _PRUNABLE_ACTIONS = {"tap", "scroll", "swipe", "wait"}
794
835
 
@@ -35,6 +35,33 @@ _ARIA_BARE_RE = re.compile(r'^(\w+)\s*(?:\[[^\]]*\]\s*)*:?\s*$')
35
35
  _MAX_WAIT_SECONDS = 10.0
36
36
 
37
37
 
38
+ # Invalid form fields after user interaction / a submit attempt (see
39
+ # PlaywrightAdapter._validation_messages).
40
+ _VALIDATION_JS = """() => Array.from(document.querySelectorAll('input, textarea, select'))
41
+ .filter(el => { try { return el.matches(':user-invalid'); } catch (e) { return false; } })
42
+ .map(el => ({
43
+ label: ((el.labels && el.labels[0] && el.labels[0].innerText) || el.getAttribute('aria-label')
44
+ || el.placeholder || el.name || el.type || 'field').trim().slice(0, 80),
45
+ message: (el.validationMessage || '').trim().slice(0, 160),
46
+ }))
47
+ .filter(f => f.message)
48
+ .slice(0, 20)"""
49
+
50
+
51
+ def _with_validation_section(screen_text: str, messages: list[tuple[str, str]]) -> str:
52
+ """Insert a "-- Form validation --" section before the page content (so the
53
+ per-step action prompt, which trims page content, keeps it)."""
54
+ if not messages:
55
+ return screen_text
56
+ section = "-- Form validation --\n" + "\n".join(
57
+ f' field "{label}": {message}' for label, message in messages
58
+ )
59
+ marker = "-- Page content --"
60
+ if marker in screen_text:
61
+ return screen_text.replace(marker, f"{section}\n{marker}", 1)
62
+ return f"{screen_text.rstrip()}\n{section}"
63
+
64
+
38
65
  class AdapterAborted(RuntimeError):
39
66
  """The adapter was aborted (e.g. by the per-goal watchdog) mid-call."""
40
67
 
@@ -278,12 +305,28 @@ class PlaywrightAdapter:
278
305
  elements = dom_elements
279
306
  screen_text = dom_text
280
307
 
308
+ screen_text = _with_validation_section(screen_text, await self._validation_messages())
309
+
281
310
  # Prepend page context so the LLM knows where it is
282
311
  header = f"=== PAGE: {page_title} ===\nURL: {page_url}\n"
283
312
  screen_text = header + screen_text
284
313
 
285
314
  return elements, screen_text
286
315
 
316
+ async def _validation_messages(self) -> list[tuple[str, str]]:
317
+ """(field label, message) for form fields the browser flags as invalid.
318
+
319
+ Native validation bubbles ("Please fill out this field.") aren't in the
320
+ accessibility tree, so without this the model and verifier can't see
321
+ them. Only :user-invalid fields are reported — ones the user interacted
322
+ with or tried to submit — so untouched required fields aren't noise.
323
+ """
324
+ try:
325
+ return [(f["label"], f["message"]) for f in await self._page.evaluate(_VALIDATION_JS)]
326
+ except Exception as exc: # noqa: BLE001 — best effort; never break screen reads
327
+ logger.debug("Could not read form validation state: %s", exc)
328
+ return []
329
+
287
330
  async def _dom_fallback_screen(self) -> tuple[list[UIElement], str]:
288
331
  """Extract interactive elements directly from the DOM via JavaScript.
289
332
  This catches elements that aria_snapshot misses on JS-heavy pages."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.4
3
+ Version: 0.2.6
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.4"
7
+ version = "0.2.6"
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"
File without changes
File without changes
File without changes