fusiontest 0.2.3__tar.gz → 0.2.4__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.3 → fusiontest-0.2.4}/PKG-INFO +1 -1
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/action_model.py +15 -5
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/runner.py +16 -10
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/playwright_adapter.py +63 -13
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/PKG-INFO +1 -1
- {fusiontest-0.2.3 → fusiontest-0.2.4}/pyproject.toml +1 -1
- {fusiontest-0.2.3 → fusiontest-0.2.4}/README.md +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/cli.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/goal_verifier.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/replay.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/screen_parser.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/secrets.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/tokens.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/macos_adapter.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/windows_adapter.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/discovery/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/discovery/goal_generator.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/guardrails/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/guardrails/engine.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/android_adapter.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/ios_adapter.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/recording/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/recording/recorder.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/reporting/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/reporting/reporter.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/__init__.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/data_collector.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/dataset_builder.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/trainer.py +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/SOURCES.txt +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/dependency_links.txt +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/entry_points.txt +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/not-zip-safe +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/requires.txt +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/top_level.txt +0 -0
- {fusiontest-0.2.3 → fusiontest-0.2.4}/setup.cfg +0 -0
|
@@ -209,6 +209,11 @@ GEMINI_DEFAULT_MODEL = "gemini-2.5-flash"
|
|
|
209
209
|
# yields empty/truncated output. Billing is per token used, not the cap.
|
|
210
210
|
LLM_MAX_OUTPUT_TOKENS = 1024
|
|
211
211
|
|
|
212
|
+
# Per-request timeout for every provider client. Without one, a hung request
|
|
213
|
+
# blocks a goal until the per-goal watchdog; a timeout is transient, so the
|
|
214
|
+
# chain retries or falls through to the next backend.
|
|
215
|
+
LLM_REQUEST_TIMEOUT_SECONDS = 120
|
|
216
|
+
|
|
212
217
|
|
|
213
218
|
# Page text the action model sees per step (ADR-007 cost work). It needs every
|
|
214
219
|
# clickable element but only the gist of the page — the verifier, which judges
|
|
@@ -449,7 +454,7 @@ class LLMCaller:
|
|
|
449
454
|
"OPENAI_API_KEY is not set. Add it to your .env file or run: "
|
|
450
455
|
"export OPENAI_API_KEY=sk-..."
|
|
451
456
|
)
|
|
452
|
-
client = OpenAI(api_key=api_key)
|
|
457
|
+
client = OpenAI(api_key=api_key, timeout=LLM_REQUEST_TIMEOUT_SECONDS)
|
|
453
458
|
|
|
454
459
|
elif backend == "claude":
|
|
455
460
|
try:
|
|
@@ -462,7 +467,7 @@ class LLMCaller:
|
|
|
462
467
|
"ANTHROPIC_API_KEY is not set. Add it to your .env file or run: "
|
|
463
468
|
"export ANTHROPIC_API_KEY=sk-ant-..."
|
|
464
469
|
)
|
|
465
|
-
client = anthropic.Anthropic(api_key=api_key)
|
|
470
|
+
client = anthropic.Anthropic(api_key=api_key, timeout=LLM_REQUEST_TIMEOUT_SECONDS)
|
|
466
471
|
|
|
467
472
|
elif backend == "ollama":
|
|
468
473
|
try:
|
|
@@ -472,7 +477,7 @@ class LLMCaller:
|
|
|
472
477
|
"Ollama backend requires the openai SDK: pip install openai"
|
|
473
478
|
) from err
|
|
474
479
|
base_url = os.getenv("OLLAMA_HOST", "http://localhost:11434") + "/v1"
|
|
475
|
-
client = OpenAI(base_url=base_url, api_key="ollama")
|
|
480
|
+
client = OpenAI(base_url=base_url, api_key="ollama", timeout=LLM_REQUEST_TIMEOUT_SECONDS)
|
|
476
481
|
|
|
477
482
|
elif backend == "groq":
|
|
478
483
|
try:
|
|
@@ -485,7 +490,8 @@ class LLMCaller:
|
|
|
485
490
|
"GROQ_API_KEY is not set. Get a free key at https://console.groq.com "
|
|
486
491
|
"then: export GROQ_API_KEY=gsk_..."
|
|
487
492
|
)
|
|
488
|
-
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=api_key
|
|
493
|
+
client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=api_key,
|
|
494
|
+
timeout=LLM_REQUEST_TIMEOUT_SECONDS)
|
|
489
495
|
|
|
490
496
|
elif backend == "gemini":
|
|
491
497
|
try:
|
|
@@ -502,7 +508,11 @@ class LLMCaller:
|
|
|
502
508
|
"GEMINI_API_KEY is not set. Add it to your .env file or run: "
|
|
503
509
|
"export GEMINI_API_KEY=AIza..."
|
|
504
510
|
)
|
|
505
|
-
|
|
511
|
+
from google.genai import types as genai_types
|
|
512
|
+
client = genai.Client(
|
|
513
|
+
api_key=api_key,
|
|
514
|
+
http_options=genai_types.HttpOptions(timeout=LLM_REQUEST_TIMEOUT_SECONDS * 1000), # ms
|
|
515
|
+
)
|
|
506
516
|
|
|
507
517
|
else:
|
|
508
518
|
raise RuntimeError(f"Unknown backend: {backend}")
|
|
@@ -318,12 +318,15 @@ class FusionTestRunner:
|
|
|
318
318
|
goal_num, total_goals, limit,
|
|
319
319
|
)
|
|
320
320
|
fired.set()
|
|
321
|
-
|
|
322
|
-
|
|
321
|
+
# Runs on the timer thread: use the adapter's thread-safe
|
|
322
|
+
# abort so the in-flight call fails now, not when it returns.
|
|
323
|
+
# (Not cancel_event — that's the user's Stop button.)
|
|
324
|
+
abort = getattr(self.adapter, "abort", None) or getattr(self.adapter, "stop", None)
|
|
323
325
|
try:
|
|
324
|
-
|
|
326
|
+
if abort is not None:
|
|
327
|
+
abort()
|
|
325
328
|
except Exception as stop_exc: # noqa: BLE001
|
|
326
|
-
logger.
|
|
329
|
+
logger.warning("Adapter abort during watchdog raised: %s", stop_exc)
|
|
327
330
|
|
|
328
331
|
watchdog = threading.Timer(self.config.max_goal_seconds, _on_timeout)
|
|
329
332
|
watchdog.daemon = True
|
|
@@ -331,18 +334,21 @@ class FusionTestRunner:
|
|
|
331
334
|
|
|
332
335
|
try:
|
|
333
336
|
step_result = self._execute_goal(goal, safe_goal, goal_url, resolver)
|
|
337
|
+
except Exception:
|
|
338
|
+
# The watchdog closing the browser makes the in-flight call raise.
|
|
339
|
+
if not watchdog_fired.is_set():
|
|
340
|
+
raise
|
|
341
|
+
step_result = StepResult(goal=safe_goal, success=False, steps_taken=0)
|
|
334
342
|
finally:
|
|
335
343
|
if watchdog is not None:
|
|
336
344
|
watchdog.cancel()
|
|
337
345
|
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
# generic Playwright error bubbled up. Also force-abort the rest of
|
|
341
|
-
# the run since the adapter has been stopped and all subsequent
|
|
342
|
-
# goals would fail instantly anyway.
|
|
346
|
+
# A timed-out goal couldn't be evaluated: record it as an error
|
|
347
|
+
# (not a test failure), and stop the run — the browser is closed.
|
|
343
348
|
goal_timed_out = watchdog_fired.is_set()
|
|
344
349
|
if goal_timed_out:
|
|
345
350
|
step_result.success = False
|
|
351
|
+
step_result.infra_error = True
|
|
346
352
|
step_result.error = (
|
|
347
353
|
f"goal_timeout: exceeded {self.config.max_goal_seconds}s wall-clock limit"
|
|
348
354
|
)
|
|
@@ -372,7 +378,7 @@ class FusionTestRunner:
|
|
|
372
378
|
|
|
373
379
|
# Watchdog closed the adapter — no point attempting further goals.
|
|
374
380
|
if goal_timed_out:
|
|
375
|
-
result.
|
|
381
|
+
result.error = step_result.error
|
|
376
382
|
result.success = False
|
|
377
383
|
logger.error(
|
|
378
384
|
"Aborting remaining %d goal(s) after per-goal timeout",
|
|
@@ -31,6 +31,14 @@ _ARIA_INLINE_RE = re.compile(r'^(\w+)\s*(?:\[[^\]]*\]\s*)*:\s*(.*\S)\s*$')
|
|
|
31
31
|
_ARIA_BARE_RE = re.compile(r'^(\w+)\s*(?:\[[^\]]*\]\s*)*:?\s*$')
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
# Upper bound for a model-requested "wait" action.
|
|
35
|
+
_MAX_WAIT_SECONDS = 10.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AdapterAborted(RuntimeError):
|
|
39
|
+
"""The adapter was aborted (e.g. by the per-goal watchdog) mid-call."""
|
|
40
|
+
|
|
41
|
+
|
|
34
42
|
class PlaywrightAdapter:
|
|
35
43
|
"""
|
|
36
44
|
Adapter that drives a browser or Electron app with Playwright
|
|
@@ -68,6 +76,9 @@ class PlaywrightAdapter:
|
|
|
68
76
|
self._page = None
|
|
69
77
|
self._parser = ScreenParser()
|
|
70
78
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
79
|
+
# abort() support: the adapter call in progress, and a close it started.
|
|
80
|
+
self._in_flight: asyncio.Task | None = None
|
|
81
|
+
self._closing: asyncio.Task | None = None
|
|
71
82
|
|
|
72
83
|
# ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
73
84
|
|
|
@@ -82,10 +93,42 @@ class PlaywrightAdapter:
|
|
|
82
93
|
|
|
83
94
|
def stop(self) -> None:
|
|
84
95
|
loop = self._get_loop()
|
|
96
|
+
# Let a close started by abort() finish before closing the loop.
|
|
97
|
+
if self._closing is not None and not self._closing.done():
|
|
98
|
+
loop.run_until_complete(self._closing)
|
|
99
|
+
self._closing = None
|
|
85
100
|
loop.run_until_complete(self.async_stop())
|
|
86
101
|
loop.close()
|
|
87
102
|
self._loop = None
|
|
88
103
|
|
|
104
|
+
def abort(self) -> None:
|
|
105
|
+
"""Interrupt the adapter from another thread (e.g. a watchdog) so the
|
|
106
|
+
call in flight fails right away: cancel it and close the browser
|
|
107
|
+
(Playwright calls like wait_for_selector only give up when their
|
|
108
|
+
browser goes away). stop() then finishes the close."""
|
|
109
|
+
loop = self._loop
|
|
110
|
+
if loop is None or not loop.is_running():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
def _interrupt() -> None:
|
|
114
|
+
# Only our own call — Playwright's internal tasks must keep running.
|
|
115
|
+
if self._in_flight is not None:
|
|
116
|
+
self._in_flight.cancel()
|
|
117
|
+
self._closing = loop.create_task(self.async_stop())
|
|
118
|
+
|
|
119
|
+
loop.call_soon_threadsafe(_interrupt)
|
|
120
|
+
|
|
121
|
+
def _run(self, coro):
|
|
122
|
+
"""Run an adapter coroutine on the adapter's loop, as a task abort() can cancel."""
|
|
123
|
+
loop = self._get_loop()
|
|
124
|
+
self._in_flight = loop.create_task(coro)
|
|
125
|
+
try:
|
|
126
|
+
return loop.run_until_complete(self._in_flight)
|
|
127
|
+
except asyncio.CancelledError as exc:
|
|
128
|
+
raise AdapterAborted("Playwright call aborted") from exc
|
|
129
|
+
finally:
|
|
130
|
+
self._in_flight = None
|
|
131
|
+
|
|
89
132
|
async def async_start(self, url: str | None = None) -> None:
|
|
90
133
|
try:
|
|
91
134
|
from playwright.async_api import async_playwright
|
|
@@ -132,17 +175,23 @@ class PlaywrightAdapter:
|
|
|
132
175
|
await self._page.wait_for_load_state("domcontentloaded")
|
|
133
176
|
|
|
134
177
|
async def async_stop(self) -> None:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
"""Close the browser and Playwright. Safe to call more than once."""
|
|
179
|
+
browser, self._browser = self._browser, None
|
|
180
|
+
playwright, self._playwright = self._playwright, None
|
|
181
|
+
for closer in (browser.close if browser else None, playwright.stop if playwright else None):
|
|
182
|
+
if closer is None:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
await closer()
|
|
186
|
+
except Exception as exc: # noqa: BLE001 — already closed / crashed
|
|
187
|
+
logger.debug("Ignoring error while stopping Playwright: %s", exc)
|
|
139
188
|
logger.info("PlaywrightAdapter stopped")
|
|
140
189
|
|
|
141
190
|
# ── Screen reading ─────────────────────────────────────────────────────────
|
|
142
191
|
|
|
143
192
|
def site_links(self, limit: int = 10) -> list[str]:
|
|
144
193
|
"""Same-origin page URLs linked from the header/navigation (fallback: any link)."""
|
|
145
|
-
return self.
|
|
194
|
+
return self._run(self._async_site_links(limit))
|
|
146
195
|
|
|
147
196
|
async def _async_site_links(self, limit: int) -> list[str]:
|
|
148
197
|
from urllib.parse import urldefrag, urljoin, urlsplit # noqa: PLC0415
|
|
@@ -171,7 +220,7 @@ class PlaywrightAdapter:
|
|
|
171
220
|
|
|
172
221
|
def navigate(self, url: str) -> None:
|
|
173
222
|
"""Navigate to a URL and wait for the page to load."""
|
|
174
|
-
self.
|
|
223
|
+
self._run(self._async_navigate(url))
|
|
175
224
|
|
|
176
225
|
async def _async_navigate(self, url: str) -> None:
|
|
177
226
|
# If the current page is closed/crashed, open a fresh one in the same context
|
|
@@ -195,7 +244,7 @@ class PlaywrightAdapter:
|
|
|
195
244
|
pass
|
|
196
245
|
|
|
197
246
|
def get_screen(self) -> tuple[list[UIElement], str]:
|
|
198
|
-
return self.
|
|
247
|
+
return self._run(self.async_get_screen())
|
|
199
248
|
|
|
200
249
|
async def async_get_screen(self) -> tuple[list[UIElement], str]:
|
|
201
250
|
"""Extract accessibility tree from the current page and parse it.
|
|
@@ -355,7 +404,7 @@ class PlaywrightAdapter:
|
|
|
355
404
|
return nodes
|
|
356
405
|
|
|
357
406
|
def get_screenshot(self) -> bytes:
|
|
358
|
-
return self.
|
|
407
|
+
return self._run(self.async_get_screenshot())
|
|
359
408
|
|
|
360
409
|
async def async_get_screenshot(self) -> bytes:
|
|
361
410
|
return await self._page.screenshot(type="png")
|
|
@@ -363,9 +412,7 @@ class PlaywrightAdapter:
|
|
|
363
412
|
# ── Action execution ───────────────────────────────────────────────────────
|
|
364
413
|
|
|
365
414
|
def execute(self, action: Action, elements: list[UIElement]) -> bool:
|
|
366
|
-
return self.
|
|
367
|
-
self.async_execute(action, elements)
|
|
368
|
-
)
|
|
415
|
+
return self._run(self.async_execute(action, elements))
|
|
369
416
|
|
|
370
417
|
async def async_execute(self, action: Action, elements: list[UIElement]) -> bool:
|
|
371
418
|
"""
|
|
@@ -384,8 +431,11 @@ class PlaywrightAdapter:
|
|
|
384
431
|
elif action.action_type == "back":
|
|
385
432
|
await self._page.go_back()
|
|
386
433
|
elif action.action_type == "wait":
|
|
387
|
-
|
|
388
|
-
|
|
434
|
+
try:
|
|
435
|
+
secs = float(action.value) if action.value else 1.5
|
|
436
|
+
except ValueError:
|
|
437
|
+
secs = 1.5
|
|
438
|
+
await asyncio.sleep(min(max(secs, 0.0), _MAX_WAIT_SECONDS))
|
|
389
439
|
elif action.action_type == "select":
|
|
390
440
|
await self._select(action, elements)
|
|
391
441
|
|
|
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
|