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.
- {fusiontest-0.2.4 → fusiontest-0.2.6}/PKG-INFO +1 -1
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/action_model.py +25 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/runner.py +51 -10
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/playwright_adapter.py +43 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/PKG-INFO +1 -1
- {fusiontest-0.2.4 → fusiontest-0.2.6}/pyproject.toml +1 -1
- {fusiontest-0.2.4 → fusiontest-0.2.6}/README.md +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/cli.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/goal_verifier.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/replay.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/screen_parser.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/secrets.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/core/tokens.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/macos_adapter.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/desktop/windows_adapter.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/discovery/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/discovery/goal_generator.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/guardrails/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/guardrails/engine.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/android_adapter.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/mobile/ios_adapter.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/recording/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/recording/recorder.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/reporting/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/reporting/reporter.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/__init__.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/data_collector.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/dataset_builder.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest/training/trainer.py +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/SOURCES.txt +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/dependency_links.txt +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/entry_points.txt +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/not-zip-safe +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/requires.txt +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/fusiontest.egg-info/top_level.txt +0 -0
- {fusiontest-0.2.4 → fusiontest-0.2.6}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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."""
|
|
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
|