camel-ai 0.2.73a1__py3-none-any.whl → 0.2.73a3__py3-none-any.whl

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.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

@@ -0,0 +1,740 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional, Tuple
18
+
19
+ from camel.logger import get_logger
20
+
21
+ from .actions import ActionExecutor
22
+ from .config_loader import ConfigLoader
23
+ from .snapshot import PageSnapshot
24
+
25
+ if TYPE_CHECKING:
26
+ from playwright.async_api import (
27
+ Browser,
28
+ BrowserContext,
29
+ Page,
30
+ Playwright,
31
+ )
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ class TabIdGenerator:
37
+ """Monotonically increasing tab ID generator."""
38
+
39
+ _counter: int = 0
40
+ _lock: ClassVar[asyncio.Lock] = asyncio.Lock()
41
+
42
+ @classmethod
43
+ async def generate_tab_id(cls) -> str:
44
+ """Generate a monotonically increasing tab ID."""
45
+ async with cls._lock:
46
+ cls._counter += 1
47
+ return f"tab-{cls._counter:03d}"
48
+
49
+
50
+ class HybridBrowserSession:
51
+ """Lightweight wrapper around Playwright for
52
+ browsing with multi-tab support.
53
+
54
+ It provides multiple *Page* instances plus helper utilities (snapshot &
55
+ executor). Multiple toolkits or agents can reuse this class without
56
+ duplicating Playwright setup code.
57
+
58
+ This class is a singleton per event-loop and session-id combination.
59
+ """
60
+
61
+ # Class-level registry for singleton instances
62
+ # Format: {(loop_id, session_id): HybridBrowserSession}
63
+ _instances: ClassVar[Dict[Tuple[Any, str], "HybridBrowserSession"]] = {}
64
+ _instances_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
65
+
66
+ _initialized: bool
67
+ _creation_params: Dict[str, Any]
68
+
69
+ def __new__(
70
+ cls,
71
+ *,
72
+ headless: bool = True,
73
+ user_data_dir: Optional[str] = None,
74
+ stealth: bool = False,
75
+ session_id: Optional[str] = None,
76
+ default_timeout: Optional[int] = None,
77
+ short_timeout: Optional[int] = None,
78
+ navigation_timeout: Optional[int] = None,
79
+ network_idle_timeout: Optional[int] = None,
80
+ ) -> "HybridBrowserSession":
81
+ # Create a unique key for this event loop and session combination
82
+ # We defer the event loop lookup to avoid issues with creation
83
+ # outside async context
84
+ instance = super().__new__(cls)
85
+ instance._initialized = False
86
+ instance._session_id = session_id or "default"
87
+ instance._creation_params = {
88
+ "headless": headless,
89
+ "user_data_dir": user_data_dir,
90
+ "stealth": stealth,
91
+ "session_id": session_id,
92
+ "default_timeout": default_timeout,
93
+ "short_timeout": short_timeout,
94
+ "navigation_timeout": navigation_timeout,
95
+ "network_idle_timeout": network_idle_timeout,
96
+ }
97
+ return instance
98
+
99
+ @classmethod
100
+ async def _get_or_create_instance(
101
+ cls,
102
+ instance: "HybridBrowserSession",
103
+ ) -> "HybridBrowserSession":
104
+ """Get or create singleton instance for the current event loop and
105
+ session."""
106
+ try:
107
+ loop = asyncio.get_running_loop()
108
+ loop_id = str(id(loop))
109
+ except RuntimeError:
110
+ # No event loop running, use a unique identifier for sync context
111
+ import threading
112
+
113
+ loop_id = f"sync_{threading.current_thread().ident}"
114
+
115
+ # Ensure session_id is never None for the key
116
+ session_id = (
117
+ instance._session_id
118
+ if instance._session_id is not None
119
+ else "default"
120
+ )
121
+ session_key = (loop_id, session_id)
122
+
123
+ # Use class-level lock to protect the instances registry
124
+ async with cls._instances_lock:
125
+ if session_key in cls._instances:
126
+ existing_instance = cls._instances[session_key]
127
+ logger.debug(
128
+ f"Reusing existing browser session for session_id: "
129
+ f"{session_id}"
130
+ )
131
+ return existing_instance
132
+
133
+ # Register this new instance
134
+ cls._instances[session_key] = instance
135
+ logger.debug(
136
+ f"Created new browser session for session_id: {session_id}"
137
+ )
138
+ return instance
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ headless: bool = True,
144
+ user_data_dir: Optional[str] = None,
145
+ stealth: bool = False,
146
+ session_id: Optional[str] = None,
147
+ default_timeout: Optional[int] = None,
148
+ short_timeout: Optional[int] = None,
149
+ navigation_timeout: Optional[int] = None,
150
+ network_idle_timeout: Optional[int] = None,
151
+ ):
152
+ if self._initialized:
153
+ return
154
+ self._initialized = True
155
+
156
+ self._headless = headless
157
+ self._user_data_dir = user_data_dir
158
+ self._stealth = stealth
159
+ self._session_id = session_id or "default"
160
+
161
+ # Store timeout configuration for ActionExecutor instances and
162
+ # browser operations
163
+ self._default_timeout = default_timeout
164
+ self._short_timeout = short_timeout
165
+ self._navigation_timeout = ConfigLoader.get_navigation_timeout(
166
+ navigation_timeout
167
+ )
168
+ self._network_idle_timeout = ConfigLoader.get_network_idle_timeout(
169
+ network_idle_timeout
170
+ )
171
+
172
+ # Initialize _creation_params to fix linter error
173
+ self._creation_params = {
174
+ "headless": headless,
175
+ "user_data_dir": user_data_dir,
176
+ "stealth": stealth,
177
+ "session_id": session_id,
178
+ "default_timeout": default_timeout,
179
+ "short_timeout": short_timeout,
180
+ "navigation_timeout": navigation_timeout,
181
+ "network_idle_timeout": network_idle_timeout,
182
+ }
183
+
184
+ self._playwright: Optional[Playwright] = None
185
+ self._browser: Optional[Browser] = None
186
+ self._context: Optional[BrowserContext] = None
187
+ self._page: Optional[Page] = None
188
+
189
+ # Dictionary-based tab management with monotonic IDs
190
+ self._pages: Dict[str, Page] = {} # tab_id -> Page object
191
+ self._current_tab_id: Optional[str] = None # Current active tab ID
192
+
193
+ self.snapshot: Optional[PageSnapshot] = None
194
+ self.executor: Optional[ActionExecutor] = None
195
+
196
+ # Protect browser initialisation against concurrent calls
197
+ self._ensure_lock: "asyncio.Lock" = asyncio.Lock()
198
+
199
+ # Load stealth script and config on initialization
200
+ self._stealth_script: Optional[str] = None
201
+ self._stealth_config: Optional[Dict[str, Any]] = None
202
+ if self._stealth:
203
+ self._stealth_script = self._load_stealth_script()
204
+ stealth_config_class = ConfigLoader.get_stealth_config()
205
+ self._stealth_config = stealth_config_class.get_stealth_config()
206
+
207
+ def _load_stealth_script(self) -> str:
208
+ r"""Load the stealth JavaScript script from file."""
209
+ import os
210
+
211
+ script_path = os.path.join(
212
+ os.path.dirname(os.path.abspath(__file__)), "stealth_script.js"
213
+ )
214
+
215
+ try:
216
+ with open(
217
+ script_path, "r", encoding='utf-8', errors='replace'
218
+ ) as f:
219
+ script_content = f.read()
220
+
221
+ if not script_content.strip():
222
+ raise ValueError(f"Stealth script is empty: {script_path}")
223
+
224
+ logger.debug(
225
+ f"Loaded stealth script ({len(script_content)} chars)"
226
+ )
227
+ return script_content
228
+ except FileNotFoundError:
229
+ logger.error(f"Stealth script not found: {script_path}")
230
+ raise FileNotFoundError(f"Stealth script not found: {script_path}")
231
+ except Exception as e:
232
+ logger.error(f"Error loading stealth script: {e}")
233
+ raise RuntimeError(f"Failed to load stealth script: {e}") from e
234
+
235
+ # ------------------------------------------------------------------
236
+ # Multi-tab management methods
237
+ # ------------------------------------------------------------------
238
+ async def create_new_tab(self, url: Optional[str] = None) -> str:
239
+ r"""Create a new tab and optionally navigate to a URL.
240
+
241
+ Args:
242
+ url: Optional URL to navigate to in the new tab
243
+
244
+ Returns:
245
+ str: ID of the newly created tab
246
+ """
247
+ await self.ensure_browser()
248
+
249
+ if self._context is None:
250
+ raise RuntimeError("Browser context is not available")
251
+
252
+ # Generate unique tab ID
253
+ tab_id = await TabIdGenerator.generate_tab_id()
254
+
255
+ # Create new page
256
+ new_page = await self._context.new_page()
257
+
258
+ # Apply stealth modifications if enabled
259
+ if self._stealth and self._stealth_script:
260
+ try:
261
+ await new_page.add_init_script(self._stealth_script)
262
+ logger.debug("Applied stealth script to new tab")
263
+ except Exception as e:
264
+ logger.warning(
265
+ f"Failed to apply stealth script to new tab: {e}"
266
+ )
267
+
268
+ # Store in pages dictionary
269
+ self._pages[tab_id] = new_page
270
+
271
+ # Navigate if URL provided
272
+ if url:
273
+ try:
274
+ await new_page.goto(url, timeout=self._navigation_timeout)
275
+ await new_page.wait_for_load_state('domcontentloaded')
276
+ except Exception as e:
277
+ logger.warning(f"Failed to navigate new tab to {url}: {e}")
278
+
279
+ logger.info(
280
+ f"Created new tab {tab_id}, total tabs: {len(self._pages)}"
281
+ )
282
+ return tab_id
283
+
284
+ async def register_page(self, new_page: "Page") -> str:
285
+ r"""Register a page that was created externally (e.g., by a click).
286
+
287
+ Args:
288
+ new_page (Page): The new page object to register.
289
+
290
+ Returns:
291
+ str: The ID of the (newly) registered tab.
292
+ """
293
+ # Check if page is already registered
294
+ for tab_id, page in self._pages.items():
295
+ if page is new_page:
296
+ return tab_id
297
+
298
+ # Create new ID for the page
299
+ tab_id = await TabIdGenerator.generate_tab_id()
300
+ self._pages[tab_id] = new_page
301
+
302
+ logger.info(
303
+ f"Registered new tab {tab_id} (opened by user action). "
304
+ f"Total tabs: {len(self._pages)}"
305
+ )
306
+ return tab_id
307
+
308
+ async def switch_to_tab(self, tab_id: str) -> bool:
309
+ r"""Switch to a specific tab by ID.
310
+
311
+ Args:
312
+ tab_id: ID of the tab to switch to
313
+
314
+ Returns:
315
+ bool: True if successful, False if tab ID is invalid
316
+ """
317
+ if tab_id not in self._pages:
318
+ logger.warning(f"Invalid tab ID: {tab_id}")
319
+ return False
320
+
321
+ page = self._pages[tab_id]
322
+
323
+ # Check if page is still valid
324
+ if page.is_closed():
325
+ logger.warning(f"Tab {tab_id} is closed, removing from registry")
326
+ # Clean up closed tab
327
+ del self._pages[tab_id]
328
+ return False
329
+
330
+ try:
331
+ # Switch to the tab
332
+ self._current_tab_id = tab_id
333
+ self._page = page
334
+
335
+ # Bring the tab to the front in the browser window
336
+ await page.bring_to_front()
337
+
338
+ # Update utilities for new tab
339
+ self.executor = ActionExecutor(
340
+ page,
341
+ self,
342
+ default_timeout=self._default_timeout,
343
+ short_timeout=self._short_timeout,
344
+ )
345
+ self.snapshot = PageSnapshot(page)
346
+
347
+ logger.info(f"Switched to tab {tab_id}")
348
+ return True
349
+
350
+ except Exception as e:
351
+ logger.warning(f"Error switching to tab {tab_id}: {e}")
352
+ return False
353
+
354
+ async def close_tab(self, tab_id: str) -> bool:
355
+ r"""Close a specific tab by ID.
356
+
357
+ Args:
358
+ tab_id: ID of the tab to close
359
+
360
+ Returns:
361
+ bool: True if successful, False if tab ID is invalid
362
+ """
363
+ if tab_id not in self._pages:
364
+ logger.warning(f"Invalid tab ID: {tab_id}")
365
+ return False
366
+
367
+ page = self._pages[tab_id]
368
+
369
+ try:
370
+ # Close the page if not already closed
371
+ if not page.is_closed():
372
+ await page.close()
373
+
374
+ # Remove from our dictionary
375
+ del self._pages[tab_id]
376
+
377
+ # If we closed the current tab, switch to another one
378
+ if tab_id == self._current_tab_id:
379
+ if self._pages:
380
+ # Switch to any available tab (first one we find)
381
+ next_tab_id = next(iter(self._pages.keys()))
382
+ await self.switch_to_tab(next_tab_id)
383
+ else:
384
+ # No tabs left
385
+ self._current_tab_id = None
386
+ self._page = None
387
+ self.executor = None
388
+ self.snapshot = None
389
+
390
+ logger.info(
391
+ f"Closed tab {tab_id}, remaining tabs: {len(self._pages)}"
392
+ )
393
+ return True
394
+
395
+ except Exception as e:
396
+ logger.warning(f"Error closing tab {tab_id}: {e}")
397
+ return False
398
+
399
+ async def get_tab_info(self) -> List[Dict[str, Any]]:
400
+ r"""Get information about all open tabs including IDs.
401
+
402
+ Returns:
403
+ List of dictionaries containing tab information
404
+ """
405
+ tab_info = []
406
+ tabs_to_cleanup = []
407
+
408
+ # Process all tabs in dictionary
409
+ for tab_id, page in list(self._pages.items()):
410
+ try:
411
+ if not page.is_closed():
412
+ title = await page.title()
413
+ url = page.url
414
+ is_current = tab_id == self._current_tab_id
415
+ tab_info.append(
416
+ {
417
+ "tab_id": tab_id,
418
+ "title": title,
419
+ "url": url,
420
+ "is_current": is_current,
421
+ }
422
+ )
423
+ else:
424
+ # Mark for cleanup
425
+ tabs_to_cleanup.append(tab_id)
426
+ except Exception as e:
427
+ logger.warning(f"Error getting info for tab {tab_id}: {e}")
428
+ tabs_to_cleanup.append(tab_id)
429
+
430
+ # Clean up closed/invalid tabs
431
+ for tab_id in tabs_to_cleanup:
432
+ if tab_id in self._pages:
433
+ del self._pages[tab_id]
434
+
435
+ return tab_info
436
+
437
+ async def get_current_tab_id(self) -> Optional[str]:
438
+ r"""Get the id for the current active tab."""
439
+ if not self._current_tab_id or not self._pages:
440
+ return None
441
+ return self._current_tab_id
442
+
443
+ # ------------------------------------------------------------------
444
+ # Browser lifecycle helpers
445
+ # ------------------------------------------------------------------
446
+ async def ensure_browser(self) -> None:
447
+ r"""Ensure browser is ready. Each session_id gets its own browser
448
+ instance."""
449
+ # First, get the singleton instance for this session
450
+ singleton_instance = await self._get_or_create_instance(self)
451
+
452
+ # If this isn't the singleton instance, delegate to the singleton
453
+ if singleton_instance is not self:
454
+ await singleton_instance.ensure_browser()
455
+ # Copy the singleton's browser state to this instance
456
+ self._playwright = singleton_instance._playwright
457
+ self._browser = singleton_instance._browser
458
+ self._context = singleton_instance._context
459
+ self._page = singleton_instance._page
460
+ self._pages = singleton_instance._pages
461
+ self._current_tab_id = singleton_instance._current_tab_id
462
+ self.snapshot = singleton_instance.snapshot
463
+ self.executor = singleton_instance.executor
464
+ return
465
+
466
+ # Serialise initialisation to avoid race conditions where multiple
467
+ # concurrent coroutine calls create multiple browser instances for
468
+ # the same HybridBrowserSession.
469
+ async with self._ensure_lock:
470
+ await self._ensure_browser_inner()
471
+
472
+ # Moved original logic to helper
473
+ async def _ensure_browser_inner(self) -> None:
474
+ r"""Internal browser initialization logic."""
475
+ from playwright.async_api import async_playwright
476
+
477
+ if self._page is not None:
478
+ return
479
+
480
+ self._playwright = await async_playwright().start()
481
+
482
+ # Prepare stealth options
483
+ launch_options: Dict[str, Any] = {"headless": self._headless}
484
+ context_options: Dict[str, Any] = {}
485
+ if self._stealth and self._stealth_config:
486
+ # Use preloaded stealth configuration
487
+ launch_options['args'] = self._stealth_config['launch_args']
488
+ context_options.update(self._stealth_config['context_options'])
489
+
490
+ if self._user_data_dir:
491
+ context = (
492
+ await self._playwright.chromium.launch_persistent_context(
493
+ user_data_dir=self._user_data_dir,
494
+ **launch_options,
495
+ **context_options,
496
+ )
497
+ )
498
+ self._context = context
499
+ # Get the first (default) page
500
+ pages = context.pages
501
+ if pages:
502
+ self._page = pages[0]
503
+ # Create ID for initial page
504
+ initial_tab_id = await TabIdGenerator.generate_tab_id()
505
+ self._pages[initial_tab_id] = pages[0]
506
+ self._current_tab_id = initial_tab_id
507
+ # Handle additional pages if any
508
+ for page in pages[1:]:
509
+ tab_id = await TabIdGenerator.generate_tab_id()
510
+ self._pages[tab_id] = page
511
+ else:
512
+ self._page = await context.new_page()
513
+ initial_tab_id = await TabIdGenerator.generate_tab_id()
514
+ self._pages[initial_tab_id] = self._page
515
+ self._current_tab_id = initial_tab_id
516
+ else:
517
+ self._browser = await self._playwright.chromium.launch(
518
+ **launch_options
519
+ )
520
+ self._context = await self._browser.new_context(**context_options)
521
+ self._page = await self._context.new_page()
522
+
523
+ # Create ID for initial page
524
+ initial_tab_id = await TabIdGenerator.generate_tab_id()
525
+ self._pages[initial_tab_id] = self._page
526
+ self._current_tab_id = initial_tab_id
527
+
528
+ # Apply stealth modifications if enabled
529
+ if self._stealth and self._stealth_script:
530
+ try:
531
+ await self._page.add_init_script(self._stealth_script)
532
+ logger.debug("Applied stealth script to main page")
533
+ except Exception as e:
534
+ logger.warning(f"Failed to apply stealth script: {e}")
535
+
536
+ # Set up timeout for navigation
537
+ self._page.set_default_navigation_timeout(self._navigation_timeout)
538
+ self._page.set_default_timeout(self._navigation_timeout)
539
+
540
+ # Initialize utilities
541
+ self.snapshot = PageSnapshot(self._page)
542
+ self.executor = ActionExecutor(
543
+ self._page,
544
+ self,
545
+ default_timeout=self._default_timeout,
546
+ short_timeout=self._short_timeout,
547
+ )
548
+
549
+ logger.info("Browser session initialized successfully")
550
+
551
+ async def close(self) -> None:
552
+ r"""Close browser session and clean up resources."""
553
+ if self._page is None:
554
+ return
555
+
556
+ try:
557
+ logger.debug("Closing browser session...")
558
+ await self._close_session()
559
+
560
+ # Remove from singleton registry
561
+ try:
562
+ try:
563
+ loop = asyncio.get_running_loop()
564
+ loop_id = str(id(loop))
565
+ except RuntimeError:
566
+ # Use same logic as _get_or_create_instance
567
+ import threading
568
+
569
+ loop_id = f"sync_{threading.current_thread().ident}"
570
+
571
+ session_id = (
572
+ self._session_id
573
+ if self._session_id is not None
574
+ else "default"
575
+ )
576
+ session_key = (loop_id, session_id)
577
+
578
+ async with self._instances_lock:
579
+ if (
580
+ session_key in self._instances
581
+ and self._instances[session_key] is self
582
+ ):
583
+ del self._instances[session_key]
584
+ logger.debug(
585
+ f"Removed session {session_id} from registry"
586
+ )
587
+
588
+ except Exception as registry_error:
589
+ logger.warning(f"Error cleaning up registry: {registry_error}")
590
+
591
+ logger.debug("Browser session closed successfully")
592
+ except Exception as e:
593
+ logger.error(f"Error during browser session close: {e}")
594
+ finally:
595
+ self._page = None
596
+ self._pages = {}
597
+ self._current_tab_id = None
598
+ self.snapshot = None
599
+ self.executor = None
600
+
601
+ async def _close_session(self) -> None:
602
+ r"""Internal session close logic with thorough cleanup."""
603
+ try:
604
+ # Close all pages first
605
+ pages_to_close = list(self._pages.values())
606
+ for page in pages_to_close:
607
+ try:
608
+ if not page.is_closed():
609
+ await page.close()
610
+ logger.debug(
611
+ f"Closed page: "
612
+ f"{page.url if hasattr(page, 'url') else 'unknown'}" # noqa:E501
613
+ )
614
+ except Exception as e:
615
+ logger.warning(f"Error closing page: {e}")
616
+
617
+ # Clear the pages dictionary
618
+ self._pages.clear()
619
+
620
+ # Close context with explicit wait
621
+ if self._context:
622
+ try:
623
+ await self._context.close()
624
+ logger.debug("Browser context closed")
625
+ except Exception as e:
626
+ logger.warning(f"Error closing context: {e}")
627
+ finally:
628
+ self._context = None
629
+
630
+ # Close browser with explicit wait
631
+ if self._browser:
632
+ try:
633
+ await self._browser.close()
634
+ logger.debug("Browser instance closed")
635
+ except Exception as e:
636
+ logger.warning(f"Error closing browser: {e}")
637
+ finally:
638
+ self._browser = None
639
+
640
+ # Stop playwright with increased delay for cleanup
641
+ if self._playwright:
642
+ try:
643
+ await self._playwright.stop()
644
+ logger.debug("Playwright stopped")
645
+
646
+ # Give more time for complete subprocess cleanup
647
+ import asyncio
648
+
649
+ await asyncio.sleep(0.5)
650
+
651
+ except Exception as e:
652
+ logger.warning(f"Error stopping playwright: {e}")
653
+ finally:
654
+ self._playwright = None
655
+
656
+ except Exception as e:
657
+ logger.error(f"Error during session cleanup: {e}")
658
+ finally:
659
+ # Ensure all attributes are cleared regardless of errors
660
+ self._page = None
661
+ self._pages = {}
662
+ self._current_tab_id = None
663
+ self._context = None
664
+ self._browser = None
665
+ self._playwright = None
666
+
667
+ @classmethod
668
+ async def close_all_sessions(cls) -> None:
669
+ r"""Close all browser sessions and clean up the singleton registry."""
670
+ logger.debug("Closing all browser sessions...")
671
+ async with cls._instances_lock:
672
+ # Close all active sessions
673
+ instances_to_close = list(cls._instances.values())
674
+ cls._instances.clear()
675
+ logger.debug(f"Closing {len(instances_to_close)} sessions.")
676
+
677
+ # Close sessions outside the lock to avoid deadlock
678
+ for instance in instances_to_close:
679
+ try:
680
+ await instance._close_session()
681
+ logger.debug(f"Closed session: {instance._session_id}")
682
+ except Exception as e:
683
+ logger.error(
684
+ f"Error closing session {instance._session_id}: {e}"
685
+ )
686
+
687
+ logger.debug("All browser sessions closed and registry cleared")
688
+
689
+ @classmethod
690
+ async def close_all(cls) -> None:
691
+ """Alias for close_all_sessions for backward compatibility."""
692
+ await cls.close_all_sessions()
693
+
694
+ # ------------------------------------------------------------------
695
+ # Page interaction
696
+ # ------------------------------------------------------------------
697
+ async def visit(self, url: str) -> str:
698
+ r"""Navigate current tab to URL."""
699
+ await self.ensure_browser()
700
+ page = await self.get_page()
701
+
702
+ await page.goto(url, timeout=self._navigation_timeout)
703
+ await page.wait_for_load_state('domcontentloaded')
704
+
705
+ # Try to wait for network idle
706
+ try:
707
+ await page.wait_for_load_state(
708
+ 'networkidle', timeout=self._network_idle_timeout
709
+ )
710
+ except Exception:
711
+ logger.debug("Network idle timeout - continuing anyway")
712
+
713
+ return f"Navigated to {url}"
714
+
715
+ async def get_snapshot(
716
+ self, *, force_refresh: bool = False, diff_only: bool = False
717
+ ) -> str:
718
+ r"""Get snapshot for current tab."""
719
+ if not self.snapshot:
720
+ return "<empty>"
721
+ return await self.snapshot.capture(
722
+ force_refresh=force_refresh, diff_only=diff_only
723
+ )
724
+
725
+ async def exec_action(self, action: Dict[str, Any]) -> Dict[str, Any]:
726
+ r"""Execute action on current tab."""
727
+ if not self.executor:
728
+ return {
729
+ "success": False,
730
+ "message": "No executor available",
731
+ "details": {},
732
+ }
733
+ return await self.executor.execute(action)
734
+
735
+ async def get_page(self) -> "Page":
736
+ r"""Get current active page."""
737
+ await self.ensure_browser()
738
+ if self._page is None:
739
+ raise RuntimeError("No active page available")
740
+ return self._page