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.
Files changed (40) hide show
  1. {fusiontest-0.2.3 → fusiontest-0.2.4}/PKG-INFO +1 -1
  2. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/action_model.py +15 -5
  3. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/runner.py +16 -10
  4. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/playwright_adapter.py +63 -13
  5. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/PKG-INFO +1 -1
  6. {fusiontest-0.2.3 → fusiontest-0.2.4}/pyproject.toml +1 -1
  7. {fusiontest-0.2.3 → fusiontest-0.2.4}/README.md +0 -0
  8. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/__init__.py +0 -0
  9. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/cli.py +0 -0
  10. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/__init__.py +0 -0
  11. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/goal_verifier.py +0 -0
  12. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/replay.py +0 -0
  13. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/screen_parser.py +0 -0
  14. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/secrets.py +0 -0
  15. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/core/tokens.py +0 -0
  16. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/__init__.py +0 -0
  17. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/macos_adapter.py +0 -0
  18. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/desktop/windows_adapter.py +0 -0
  19. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/discovery/__init__.py +0 -0
  20. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/discovery/goal_generator.py +0 -0
  21. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/guardrails/__init__.py +0 -0
  22. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/guardrails/engine.py +0 -0
  23. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/__init__.py +0 -0
  24. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/android_adapter.py +0 -0
  25. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/mobile/ios_adapter.py +0 -0
  26. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/recording/__init__.py +0 -0
  27. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/recording/recorder.py +0 -0
  28. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/reporting/__init__.py +0 -0
  29. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/reporting/reporter.py +0 -0
  30. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/__init__.py +0 -0
  31. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/data_collector.py +0 -0
  32. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/dataset_builder.py +0 -0
  33. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest/training/trainer.py +0 -0
  34. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/SOURCES.txt +0 -0
  35. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/dependency_links.txt +0 -0
  36. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/entry_points.txt +0 -0
  37. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/not-zip-safe +0 -0
  38. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/requires.txt +0 -0
  39. {fusiontest-0.2.3 → fusiontest-0.2.4}/fusiontest.egg-info/top_level.txt +0 -0
  40. {fusiontest-0.2.3 → fusiontest-0.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.3
3
+ Version: 0.2.4
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
@@ -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
- client = genai.Client(api_key=api_key)
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
- if cancel_event is not None:
322
- cancel_event.set()
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
- self.adapter.stop()
326
+ if abort is not None:
327
+ abort()
325
328
  except Exception as stop_exc: # noqa: BLE001
326
- logger.debug("Adapter stop during watchdog raised: %s", stop_exc)
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
- # If the watchdog actually fired, record a clear timeout reason on
339
- # the step so the UI can show "goal_timeout" rather than whatever
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.cancelled = True
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
- if self._browser:
136
- await self._browser.close()
137
- if self._playwright:
138
- await self._playwright.stop()
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._get_loop().run_until_complete(self._async_site_links(limit))
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._get_loop().run_until_complete(self._async_navigate(url))
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._get_loop().run_until_complete(self.async_get_screen())
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._get_loop().run_until_complete(self.async_get_screenshot())
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._get_loop().run_until_complete(
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
- secs = float(action.value) if action.value else 1.5
388
- await asyncio.sleep(secs)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.3
3
+ Version: 0.2.4
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.3"
7
+ version = "0.2.4"
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