fusiontest 0.2.2__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.2 → fusiontest-0.2.4}/PKG-INFO +1 -1
  2. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/action_model.py +71 -6
  3. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/runner.py +30 -13
  4. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/tokens.py +6 -0
  5. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/playwright_adapter.py +63 -13
  6. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/discovery/goal_generator.py +1 -0
  7. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/PKG-INFO +1 -1
  8. {fusiontest-0.2.2 → fusiontest-0.2.4}/pyproject.toml +1 -1
  9. {fusiontest-0.2.2 → fusiontest-0.2.4}/README.md +0 -0
  10. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/__init__.py +0 -0
  11. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/cli.py +0 -0
  12. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/__init__.py +0 -0
  13. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/goal_verifier.py +0 -0
  14. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/replay.py +0 -0
  15. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/screen_parser.py +0 -0
  16. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/secrets.py +0 -0
  17. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/__init__.py +0 -0
  18. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/macos_adapter.py +0 -0
  19. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/windows_adapter.py +0 -0
  20. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/discovery/__init__.py +0 -0
  21. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/guardrails/__init__.py +0 -0
  22. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/guardrails/engine.py +0 -0
  23. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/__init__.py +0 -0
  24. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/android_adapter.py +0 -0
  25. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/ios_adapter.py +0 -0
  26. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/recording/__init__.py +0 -0
  27. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/recording/recorder.py +0 -0
  28. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/reporting/__init__.py +0 -0
  29. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/reporting/reporter.py +0 -0
  30. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/__init__.py +0 -0
  31. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/data_collector.py +0 -0
  32. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/dataset_builder.py +0 -0
  33. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/trainer.py +0 -0
  34. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/SOURCES.txt +0 -0
  35. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/dependency_links.txt +0 -0
  36. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/entry_points.txt +0 -0
  37. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/not-zip-safe +0 -0
  38. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/requires.txt +0 -0
  39. {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/top_level.txt +0 -0
  40. {fusiontest-0.2.2 → 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.2
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,57 @@ 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
+
217
+
218
+ # Page text the action model sees per step (ADR-007 cost work). It needs every
219
+ # clickable element but only the gist of the page — the verifier, which judges
220
+ # content, gets the full page. Measured on fusionleap.io, page text was ~65% of
221
+ # each ~2.6k-token step prompt.
222
+ _ACTION_CONTENT_CHARS = 1500
223
+ _ACTION_LABEL_CHARS = 80
224
+ _ACTION_ELEMENT_CHARS = 120
225
+ _CONTENT_LINE_RE = re.compile(r'^\s*(\w+): "(.*)"$')
226
+ _ELEMENT_LINE_RE = re.compile(r'^\s*\[\d+\] ')
227
+
228
+
229
+ def compact_screen_for_action(screen_text: str) -> str:
230
+ """Trim a screen's page-content section for the per-step action prompt.
231
+
232
+ Every interactive element is kept (the model can only act on what it sees),
233
+ but very long labels — e.g. list items holding whole paragraphs — are cut:
234
+ the model acts by index. In the page content, headings are kept whole,
235
+ other text is cut to 80 characters, and the section stops at ~1,500 characters.
236
+ """
237
+ lines: list[str] = []
238
+ in_content = False
239
+ used = 0
240
+ for line in screen_text.splitlines():
241
+ if line.startswith("-- "):
242
+ in_content = line.startswith("-- Page content")
243
+ lines.append(line)
244
+ continue
245
+ if not in_content:
246
+ if len(line) > _ACTION_ELEMENT_CHARS and _ELEMENT_LINE_RE.match(line):
247
+ line = line[:_ACTION_ELEMENT_CHARS] + '…"'
248
+ lines.append(line)
249
+ continue
250
+ match = _CONTENT_LINE_RE.match(line)
251
+ if match:
252
+ kind, label = match.groups()
253
+ if kind != "heading" and len(label) > _ACTION_LABEL_CHARS:
254
+ label = label[:_ACTION_LABEL_CHARS] + "…"
255
+ line = f' {kind}: "{label}"'
256
+ if used + len(line) > _ACTION_CONTENT_CHARS:
257
+ lines.append(" … (more page text omitted for brevity)")
258
+ break
259
+ used += len(line)
260
+ lines.append(line)
261
+ return "\n".join(lines)
262
+
212
263
 
213
264
  def openai_compat_extra_args(model: str) -> dict:
214
265
  """Extra chat.completions args for OpenAI-compatible models (Groq/OpenAI/Ollama)."""
@@ -321,6 +372,10 @@ class LLMRequest:
321
372
  json_object: bool = False # OpenAI-compatible response_format={"type": "json_object"}
322
373
  empty: str = "" # returned when the model produces no text
323
374
  max_output_tokens: int = LLM_MAX_OUTPUT_TOKENS # includes hidden reasoning/thinking
375
+ # Let reasoning models think before answering. Off for per-step action and
376
+ # verifier calls (short answers; thinking tokens are billed as output and
377
+ # add latency); on for open-ended generation like goal discovery.
378
+ thinking: bool = False
324
379
 
325
380
 
326
381
  class LLMCaller:
@@ -399,7 +454,7 @@ class LLMCaller:
399
454
  "OPENAI_API_KEY is not set. Add it to your .env file or run: "
400
455
  "export OPENAI_API_KEY=sk-..."
401
456
  )
402
- client = OpenAI(api_key=api_key)
457
+ client = OpenAI(api_key=api_key, timeout=LLM_REQUEST_TIMEOUT_SECONDS)
403
458
 
404
459
  elif backend == "claude":
405
460
  try:
@@ -412,7 +467,7 @@ class LLMCaller:
412
467
  "ANTHROPIC_API_KEY is not set. Add it to your .env file or run: "
413
468
  "export ANTHROPIC_API_KEY=sk-ant-..."
414
469
  )
415
- client = anthropic.Anthropic(api_key=api_key)
470
+ client = anthropic.Anthropic(api_key=api_key, timeout=LLM_REQUEST_TIMEOUT_SECONDS)
416
471
 
417
472
  elif backend == "ollama":
418
473
  try:
@@ -422,7 +477,7 @@ class LLMCaller:
422
477
  "Ollama backend requires the openai SDK: pip install openai"
423
478
  ) from err
424
479
  base_url = os.getenv("OLLAMA_HOST", "http://localhost:11434") + "/v1"
425
- client = OpenAI(base_url=base_url, api_key="ollama")
480
+ client = OpenAI(base_url=base_url, api_key="ollama", timeout=LLM_REQUEST_TIMEOUT_SECONDS)
426
481
 
427
482
  elif backend == "groq":
428
483
  try:
@@ -435,7 +490,8 @@ class LLMCaller:
435
490
  "GROQ_API_KEY is not set. Get a free key at https://console.groq.com "
436
491
  "then: export GROQ_API_KEY=gsk_..."
437
492
  )
438
- 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)
439
495
 
440
496
  elif backend == "gemini":
441
497
  try:
@@ -452,7 +508,11 @@ class LLMCaller:
452
508
  "GEMINI_API_KEY is not set. Add it to your .env file or run: "
453
509
  "export GEMINI_API_KEY=AIza..."
454
510
  )
455
- 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
+ )
456
516
 
457
517
  else:
458
518
  raise RuntimeError(f"Unknown backend: {backend}")
@@ -609,6 +669,11 @@ class LLMCaller:
609
669
  system_instruction=request.system,
610
670
  temperature=self.temperature,
611
671
  max_output_tokens=request.max_output_tokens,
672
+ # Flash models can skip thinking entirely; Pro models can't.
673
+ thinking_config=(
674
+ genai_types.ThinkingConfig(thinking_budget=0)
675
+ if not request.thinking and "flash" in resolved_model else None
676
+ ),
612
677
  ),
613
678
  )
614
679
  meta = getattr(resp, "usage_metadata", None)
@@ -820,7 +885,7 @@ class ActionModel(LLMCaller):
820
885
  pruned_invalid = invalid_actions[-self.max_invalid_actions_in_prompt:]
821
886
  sections += ["", "ACTIONS THAT FAILED (do NOT repeat):", *[f" - {a}" for a in pruned_invalid]]
822
887
 
823
- trimmed_screen = screen_text
888
+ trimmed_screen = compact_screen_for_action(screen_text)
824
889
  if self.max_screen_chars and len(trimmed_screen) > self.max_screen_chars:
825
890
  # Truncate at the last newline before the limit to avoid cutting an
826
891
  # element in half. Interactive elements are listed first, so only
@@ -60,7 +60,8 @@ class StepResult:
60
60
  screenshots: list[bytes] = field(default_factory=list)
61
61
  error: str = ""
62
62
  duration_seconds: float = 0.0
63
- token_usage: TokenUsage = field(default_factory=TokenUsage)
63
+ token_usage: TokenUsage = field(default_factory=TokenUsage) # all LLM calls
64
+ verifier_tokens: TokenUsage = field(default_factory=TokenUsage) # the verifier's share
64
65
  # The goal could not be evaluated (LLM capacity / infrastructure). Not a
65
66
  # test result: excluded from stability and reported as an error (ADR-007).
66
67
  infra_error: bool = False
@@ -80,6 +81,10 @@ class StepResult:
80
81
  "error": self.error,
81
82
  "duration_seconds": self.duration_seconds,
82
83
  "token_usage": self.token_usage.to_dict(),
84
+ "token_breakdown": {
85
+ "action": (self.token_usage - self.verifier_tokens).to_dict(),
86
+ "verifier": self.verifier_tokens.to_dict(),
87
+ },
83
88
  "mode": self.mode,
84
89
  }
85
90
 
@@ -313,12 +318,15 @@ class FusionTestRunner:
313
318
  goal_num, total_goals, limit,
314
319
  )
315
320
  fired.set()
316
- if cancel_event is not None:
317
- 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)
318
325
  try:
319
- self.adapter.stop()
326
+ if abort is not None:
327
+ abort()
320
328
  except Exception as stop_exc: # noqa: BLE001
321
- logger.debug("Adapter stop during watchdog raised: %s", stop_exc)
329
+ logger.warning("Adapter abort during watchdog raised: %s", stop_exc)
322
330
 
323
331
  watchdog = threading.Timer(self.config.max_goal_seconds, _on_timeout)
324
332
  watchdog.daemon = True
@@ -326,18 +334,21 @@ class FusionTestRunner:
326
334
 
327
335
  try:
328
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)
329
342
  finally:
330
343
  if watchdog is not None:
331
344
  watchdog.cancel()
332
345
 
333
- # If the watchdog actually fired, record a clear timeout reason on
334
- # the step so the UI can show "goal_timeout" rather than whatever
335
- # generic Playwright error bubbled up. Also force-abort the rest of
336
- # the run since the adapter has been stopped and all subsequent
337
- # 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.
338
348
  goal_timed_out = watchdog_fired.is_set()
339
349
  if goal_timed_out:
340
350
  step_result.success = False
351
+ step_result.infra_error = True
341
352
  step_result.error = (
342
353
  f"goal_timeout: exceeded {self.config.max_goal_seconds}s wall-clock limit"
343
354
  )
@@ -367,7 +378,7 @@ class FusionTestRunner:
367
378
 
368
379
  # Watchdog closed the adapter — no point attempting further goals.
369
380
  if goal_timed_out:
370
- result.cancelled = True
381
+ result.error = step_result.error
371
382
  result.success = False
372
383
  logger.error(
373
384
  "Aborting remaining %d goal(s) after per-goal timeout",
@@ -506,7 +517,7 @@ class FusionTestRunner:
506
517
  action_str = _redact(f"done: {action.reasoning}")
507
518
  step.actions.append(action_str)
508
519
  verified = self.verifier.is_complete(goal, screen_text, history)
509
- step.token_usage += getattr(self.verifier, "last_token_usage", TokenUsage())
520
+ self._add_verifier_tokens(step)
510
521
  step.success = verified
511
522
  if verified:
512
523
  self._last_recording = self._make_recording(recorded, screen_text)
@@ -607,7 +618,7 @@ class FusionTestRunner:
607
618
  f" Goal verified complete at max_steps "
608
619
  f"({self.config.max_steps_per_goal}) — last-chance check passed"
609
620
  )
610
- step.token_usage += getattr(self.verifier, "last_token_usage", TokenUsage())
621
+ self._add_verifier_tokens(step)
611
622
 
612
623
  if not step.success and self.config.screenshot_on_failure:
613
624
  try:
@@ -744,6 +755,12 @@ class FusionTestRunner:
744
755
  )
745
756
  return replace(action, value=resolver.resolve(action.value)), None
746
757
 
758
+ def _add_verifier_tokens(self, step: StepResult) -> None:
759
+ usage = getattr(self.verifier, "last_token_usage", None)
760
+ if isinstance(usage, TokenUsage):
761
+ step.token_usage += usage
762
+ step.verifier_tokens += usage
763
+
747
764
  def _write_step_log(
748
765
  self, goal: str, screen_text: str, action: str, outcome: str
749
766
  ) -> None:
@@ -25,6 +25,12 @@ class TokenUsage:
25
25
  output_tokens=self.output_tokens + other.output_tokens,
26
26
  )
27
27
 
28
+ def __sub__(self, other: TokenUsage) -> TokenUsage:
29
+ return TokenUsage(
30
+ input_tokens=self.input_tokens - other.input_tokens,
31
+ output_tokens=self.output_tokens - other.output_tokens,
32
+ )
33
+
28
34
  def __iadd__(self, other: TokenUsage) -> TokenUsage:
29
35
  self.input_tokens += other.input_tokens
30
36
  self.output_tokens += other.output_tokens
@@ -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
 
@@ -147,6 +147,7 @@ _REQUEST = LLMRequest(
147
147
  empty="{}",
148
148
  # 20 goals of JSON plus the model's hidden reasoning/thinking.
149
149
  max_output_tokens=8192,
150
+ thinking=True, # open-ended generation benefits from thinking
150
151
  )
151
152
 
152
153
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fusiontest
3
- Version: 0.2.2
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.2"
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