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.
- {fusiontest-0.2.2 → fusiontest-0.2.4}/PKG-INFO +1 -1
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/action_model.py +71 -6
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/runner.py +30 -13
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/tokens.py +6 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/playwright_adapter.py +63 -13
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/discovery/goal_generator.py +1 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/PKG-INFO +1 -1
- {fusiontest-0.2.2 → fusiontest-0.2.4}/pyproject.toml +1 -1
- {fusiontest-0.2.2 → fusiontest-0.2.4}/README.md +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/cli.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/goal_verifier.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/replay.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/screen_parser.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/core/secrets.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/macos_adapter.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/desktop/windows_adapter.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/discovery/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/guardrails/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/guardrails/engine.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/android_adapter.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/mobile/ios_adapter.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/recording/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/recording/recorder.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/reporting/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/reporting/reporter.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/__init__.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/data_collector.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/dataset_builder.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest/training/trainer.py +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/SOURCES.txt +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/dependency_links.txt +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/entry_points.txt +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/not-zip-safe +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/requires.txt +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/fusiontest.egg-info/top_level.txt +0 -0
- {fusiontest-0.2.2 → fusiontest-0.2.4}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
326
|
+
if abort is not None:
|
|
327
|
+
abort()
|
|
320
328
|
except Exception as stop_exc: # noqa: BLE001
|
|
321
|
-
logger.
|
|
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
|
-
#
|
|
334
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|