sentienceapi 0.92.2__py3-none-any.whl → 0.98.0__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 sentienceapi might be problematic. Click here for more details.

Files changed (64) hide show
  1. sentience/__init__.py +107 -2
  2. sentience/_extension_loader.py +156 -1
  3. sentience/action_executor.py +2 -0
  4. sentience/actions.py +354 -9
  5. sentience/agent.py +4 -0
  6. sentience/agent_runtime.py +840 -0
  7. sentience/asserts/__init__.py +70 -0
  8. sentience/asserts/expect.py +621 -0
  9. sentience/asserts/query.py +383 -0
  10. sentience/async_api.py +8 -1
  11. sentience/backends/__init__.py +137 -0
  12. sentience/backends/actions.py +372 -0
  13. sentience/backends/browser_use_adapter.py +241 -0
  14. sentience/backends/cdp_backend.py +393 -0
  15. sentience/backends/exceptions.py +211 -0
  16. sentience/backends/playwright_backend.py +194 -0
  17. sentience/backends/protocol.py +216 -0
  18. sentience/backends/sentience_context.py +469 -0
  19. sentience/backends/snapshot.py +483 -0
  20. sentience/browser.py +230 -74
  21. sentience/canonicalization.py +207 -0
  22. sentience/cloud_tracing.py +65 -24
  23. sentience/constants.py +6 -0
  24. sentience/cursor_policy.py +142 -0
  25. sentience/extension/content.js +35 -0
  26. sentience/extension/injected_api.js +310 -15
  27. sentience/extension/manifest.json +1 -1
  28. sentience/extension/pkg/sentience_core.d.ts +22 -22
  29. sentience/extension/pkg/sentience_core.js +192 -144
  30. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  31. sentience/extension/release.json +29 -29
  32. sentience/failure_artifacts.py +241 -0
  33. sentience/integrations/__init__.py +6 -0
  34. sentience/integrations/langchain/__init__.py +12 -0
  35. sentience/integrations/langchain/context.py +18 -0
  36. sentience/integrations/langchain/core.py +326 -0
  37. sentience/integrations/langchain/tools.py +180 -0
  38. sentience/integrations/models.py +46 -0
  39. sentience/integrations/pydanticai/__init__.py +15 -0
  40. sentience/integrations/pydanticai/deps.py +20 -0
  41. sentience/integrations/pydanticai/toolset.py +468 -0
  42. sentience/llm_provider.py +695 -18
  43. sentience/models.py +536 -3
  44. sentience/ordinal.py +280 -0
  45. sentience/query.py +66 -4
  46. sentience/schemas/trace_v1.json +27 -1
  47. sentience/snapshot.py +384 -93
  48. sentience/snapshot_diff.py +39 -54
  49. sentience/text_search.py +1 -0
  50. sentience/trace_event_builder.py +20 -1
  51. sentience/trace_indexing/indexer.py +3 -49
  52. sentience/tracer_factory.py +1 -3
  53. sentience/verification.py +618 -0
  54. sentience/visual_agent.py +3 -1
  55. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/METADATA +198 -40
  56. sentienceapi-0.98.0.dist-info/RECORD +92 -0
  57. sentience/utils.py +0 -296
  58. sentienceapi-0.92.2.dist-info/RECORD +0 -65
  59. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/WHEEL +0 -0
  60. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/entry_points.txt +0 -0
  61. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE +0 -0
  62. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-APACHE +0 -0
  63. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/licenses/LICENSE-MIT +0 -0
  64. {sentienceapi-0.92.2.dist-info → sentienceapi-0.98.0.dist-info}/top_level.txt +0 -0
sentience/browser.py CHANGED
@@ -3,7 +3,9 @@ Playwright browser harness with extension loading
3
3
  """
4
4
 
5
5
  import asyncio
6
+ import logging
6
7
  import os
8
+ import platform
7
9
  import shutil
8
10
  import tempfile
9
11
  import time
@@ -18,8 +20,11 @@ from playwright.async_api import async_playwright
18
20
  from playwright.sync_api import BrowserContext, Page, Playwright, sync_playwright
19
21
 
20
22
  from sentience._extension_loader import find_extension_path
23
+ from sentience.constants import SENTIENCE_API_URL
21
24
  from sentience.models import ProxyConfig, StorageState, Viewport
22
25
 
26
+ logger = logging.getLogger(__name__)
27
+
23
28
  # Import stealth for bot evasion (optional - graceful fallback if not available)
24
29
  try:
25
30
  from playwright_stealth import stealth_async, stealth_sync
@@ -86,7 +91,7 @@ class SentienceBrowser:
86
91
  # Only set api_url if api_key is provided, otherwise None (free tier)
87
92
  # Defaults to production API if key is present but url is missing
88
93
  if self.api_key and not api_url:
89
- self.api_url = "https://api.sentienceapi.com"
94
+ self.api_url = SENTIENCE_API_URL
90
95
  else:
91
96
  self.api_url = api_url
92
97
 
@@ -145,14 +150,16 @@ class SentienceBrowser:
145
150
 
146
151
  # Validate scheme
147
152
  if parsed.scheme not in ("http", "https", "socks5"):
148
- print(f"⚠️ [Sentience] Unsupported proxy scheme: {parsed.scheme}")
149
- print(" Supported: http, https, socks5")
153
+ logger.warning(
154
+ f"Unsupported proxy scheme: {parsed.scheme}. Supported: http, https, socks5"
155
+ )
150
156
  return None
151
157
 
152
158
  # Validate host and port
153
159
  if not parsed.hostname or not parsed.port:
154
- print("⚠️ [Sentience] Proxy URL must include hostname and port")
155
- print(" Expected format: http://username:password@host:port")
160
+ logger.warning(
161
+ "Proxy URL must include hostname and port. Expected format: http://username:password@host:port"
162
+ )
156
163
  return None
157
164
 
158
165
  # Build server URL
@@ -166,8 +173,9 @@ class SentienceBrowser:
166
173
  )
167
174
 
168
175
  except Exception as e:
169
- print(f"⚠️ [Sentience] Invalid proxy configuration: {e}")
170
- print(" Expected format: http://username:password@host:port")
176
+ logger.warning(
177
+ f"Invalid proxy configuration: {e}. Expected format: http://username:password@host:port"
178
+ )
171
179
  return None
172
180
 
173
181
  def start(self) -> None:
@@ -187,13 +195,41 @@ class SentienceBrowser:
187
195
  f"--disable-extensions-except={self._extension_path}",
188
196
  f"--load-extension={self._extension_path}",
189
197
  "--disable-blink-features=AutomationControlled", # Hides 'navigator.webdriver'
190
- "--no-sandbox",
191
198
  "--disable-infobars",
192
199
  # WebRTC leak protection (prevents real IP exposure when using proxies/VPNs)
193
200
  "--disable-features=WebRtcHideLocalIpsWithMdns",
194
201
  "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
195
202
  ]
196
203
 
204
+ # Only add --no-sandbox on Linux (causes crashes on macOS)
205
+ # macOS sandboxing works fine and the flag actually causes crashes
206
+ if platform.system() == "Linux":
207
+ args.append("--no-sandbox")
208
+
209
+ # Add GPU-disabling flags for macOS to prevent Chrome for Testing crash-on-exit
210
+ # These flags help avoid EXC_BAD_ACCESS crashes during browser shutdown
211
+ if platform.system() == "Darwin": # macOS
212
+ args.extend(
213
+ [
214
+ "--disable-gpu",
215
+ "--disable-software-rasterizer",
216
+ "--disable-dev-shm-usage",
217
+ "--disable-breakpad", # Disable crash reporter to prevent macOS crash dialogs
218
+ "--disable-crash-reporter", # Disable crash reporter UI
219
+ "--disable-crash-handler", # Disable crash handler completely
220
+ "--disable-in-process-stack-traces", # Disable stack trace collection
221
+ "--disable-hang-monitor", # Disable hang detection
222
+ "--disable-background-networking", # Disable background networking
223
+ "--disable-background-timer-throttling", # Disable background throttling
224
+ "--disable-backgrounding-occluded-windows", # Disable backgrounding
225
+ "--disable-renderer-backgrounding", # Disable renderer backgrounding
226
+ "--disable-features=TranslateUI", # Disable translate UI
227
+ "--disable-ipc-flooding-protection", # Disable IPC flooding protection
228
+ "--disable-logging", # Disable logging to reduce stderr noise
229
+ "--log-level=3", # Set log level to fatal only (suppresses warnings)
230
+ ]
231
+ )
232
+
197
233
  # Handle headless mode correctly for extensions
198
234
  # 'headless=True' DOES NOT support extensions in standard Chrome
199
235
  # We must use 'headless="new"' (Chrome 112+) or run visible
@@ -219,6 +255,8 @@ class SentienceBrowser:
219
255
  "viewport": {"width": self.viewport.width, "height": self.viewport.height},
220
256
  # Remove "HeadlessChrome" from User Agent automatically
221
257
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
258
+ # Note: Don't set "channel" - let Playwright use its default managed Chromium
259
+ # Setting channel=None doesn't force bundled Chromium and can still pick Chrome for Testing
222
260
  }
223
261
 
224
262
  # Add device scale factor if configured
@@ -230,7 +268,7 @@ class SentienceBrowser:
230
268
  launch_params["proxy"] = proxy_config.to_playwright_dict()
231
269
  # Ignore HTTPS errors when using proxy (many residential proxies use self-signed certs)
232
270
  launch_params["ignore_https_errors"] = True
233
- print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
271
+ logger.info(f"Using proxy: {proxy_config.server}")
234
272
 
235
273
  # Add video recording if configured
236
274
  if self.record_video_dir:
@@ -238,9 +276,8 @@ class SentienceBrowser:
238
276
  video_dir.mkdir(parents=True, exist_ok=True)
239
277
  launch_params["record_video_dir"] = str(video_dir)
240
278
  launch_params["record_video_size"] = self.record_video_size
241
- print(f"🎥 [Sentience] Recording video to: {video_dir}")
242
- print(
243
- f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
279
+ logger.info(
280
+ f"Recording video to: {video_dir} (Resolution: {self.record_video_size['width']}x{self.record_video_size['height']})"
244
281
  )
245
282
 
246
283
  # Launch persistent context (required for extensions)
@@ -346,7 +383,7 @@ class SentienceBrowser:
346
383
  playwright_cookies.append(playwright_cookie)
347
384
 
348
385
  self.context.add_cookies(playwright_cookies)
349
- print(f"✅ [Sentience] Injected {len(state.cookies)} cookie(s)")
386
+ logger.debug(f"Injected {len(state.cookies)} cookie(s)")
350
387
 
351
388
  # Inject LocalStorage (requires navigation to each domain)
352
389
  if state.origins:
@@ -373,11 +410,11 @@ class SentienceBrowser:
373
410
  }""",
374
411
  localStorage_dict,
375
412
  )
376
- print(
377
- f"✅ [Sentience] Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
413
+ logger.debug(
414
+ f"Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
378
415
  )
379
416
  except Exception as e:
380
- print(f"⚠️ [Sentience] Failed to inject localStorage for {origin}: {e}")
417
+ logger.warning(f"Failed to inject localStorage for {origin}: {e}")
381
418
 
382
419
  def _wait_for_extension(self, timeout_sec: float = 5.0) -> bool:
383
420
  """Poll for window.sentience to be available"""
@@ -438,30 +475,15 @@ class SentienceBrowser:
438
475
  Note: Video files are saved automatically by Playwright when context closes.
439
476
  If multiple pages exist, returns the path to the first page's video.
440
477
  """
441
- temp_video_path = None
442
-
443
- # Get video path before closing (if recording was enabled)
444
- # Note: Playwright saves videos when pages/context close, but we can get the
445
- # expected path before closing. The actual file will be available after close.
446
- if self.record_video_dir:
447
- try:
448
- # Try to get video path from the first page
449
- if self.page and self.page.video:
450
- temp_video_path = self.page.video.path()
451
- # If that fails, check all pages in the context
452
- elif self.context:
453
- for page in self.context.pages:
454
- if page.video:
455
- temp_video_path = page.video.path()
456
- break
457
- except Exception:
458
- # Video path might not be available until after close
459
- # In that case, we'll return None and user can check the directory
460
- pass
478
+ # CRITICAL: Don't access page.video.path() BEFORE closing context
479
+ # This can poke the video subsystem at an awkward time and cause crashes on macOS
480
+ # Instead, we'll locate the video file after context closes
461
481
 
462
482
  # Close context (this triggers video file finalization)
463
483
  if self.context:
464
484
  self.context.close()
485
+ # Small grace period to ensure video file is fully flushed to disk
486
+ time.sleep(0.5)
465
487
 
466
488
  # Close playwright
467
489
  if self.playwright:
@@ -471,8 +493,24 @@ class SentienceBrowser:
471
493
  if self._extension_path and os.path.exists(self._extension_path):
472
494
  shutil.rmtree(self._extension_path)
473
495
 
496
+ # NOW resolve video path after context is closed and video is finalized
497
+ temp_video_path = None
498
+ if self.record_video_dir:
499
+ try:
500
+ # Locate the newest .webm file in record_video_dir
501
+ # This avoids touching page.video during teardown
502
+ video_dir = Path(self.record_video_dir)
503
+ if video_dir.exists():
504
+ webm_files = list(video_dir.glob("*.webm"))
505
+ if webm_files:
506
+ # Get the most recently modified file
507
+ temp_video_path = max(webm_files, key=lambda p: p.stat().st_mtime)
508
+ logger.debug(f"Found video file: {temp_video_path}")
509
+ except Exception as e:
510
+ logger.warning(f"Could not locate video file: {e}")
511
+
474
512
  # Rename/move video if output_path is specified
475
- final_path = temp_video_path
513
+ final_path = str(temp_video_path) if temp_video_path else None
476
514
  if temp_video_path and output_path and os.path.exists(temp_video_path):
477
515
  try:
478
516
  output_path = str(output_path)
@@ -485,7 +523,7 @@ class SentienceBrowser:
485
523
 
486
524
  warnings.warn(f"Failed to rename video file: {e}")
487
525
  # Return original path if rename fails
488
- final_path = temp_video_path
526
+ final_path = str(temp_video_path)
489
527
 
490
528
  return final_path
491
529
 
@@ -605,6 +643,7 @@ class AsyncSentienceBrowser:
605
643
  record_video_size: dict[str, int] | None = None,
606
644
  viewport: Viewport | dict[str, int] | None = None,
607
645
  device_scale_factor: float | None = None,
646
+ executable_path: str | None = None,
608
647
  ):
609
648
  """
610
649
  Initialize Async Sentience browser
@@ -629,11 +668,15 @@ class AsyncSentienceBrowser:
629
668
  2.0 (Retina/high-DPI, like MacBook Pro)
630
669
  3.0 (very high DPI)
631
670
  If None, defaults to 1.0 (standard DPI).
671
+ executable_path: Optional path to Chromium executable. If provided, forces use of
672
+ this specific browser binary instead of Playwright's managed browser.
673
+ Useful to guarantee Chromium (not Chrome for Testing) on macOS.
674
+ Example: "/path/to/playwright/chromium-1234/chrome-mac/Chromium.app/Contents/MacOS/Chromium"
632
675
  """
633
676
  self.api_key = api_key
634
677
  # Only set api_url if api_key is provided, otherwise None (free tier)
635
678
  if self.api_key and not api_url:
636
- self.api_url = "https://api.sentienceapi.com"
679
+ self.api_url = SENTIENCE_API_URL
637
680
  else:
638
681
  self.api_url = api_url
639
682
 
@@ -666,6 +709,9 @@ class AsyncSentienceBrowser:
666
709
  # Device scale factor for high-DPI emulation
667
710
  self.device_scale_factor = device_scale_factor
668
711
 
712
+ # Executable path override (for forcing specific Chromium binary)
713
+ self.executable_path = executable_path
714
+
669
715
  self.playwright: AsyncPlaywright | None = None
670
716
  self.context: AsyncBrowserContext | None = None
671
717
  self.page: AsyncPage | None = None
@@ -689,14 +735,16 @@ class AsyncSentienceBrowser:
689
735
 
690
736
  # Validate scheme
691
737
  if parsed.scheme not in ("http", "https", "socks5"):
692
- print(f"⚠️ [Sentience] Unsupported proxy scheme: {parsed.scheme}")
693
- print(" Supported: http, https, socks5")
738
+ logger.warning(
739
+ f"Unsupported proxy scheme: {parsed.scheme}. Supported: http, https, socks5"
740
+ )
694
741
  return None
695
742
 
696
743
  # Validate host and port
697
744
  if not parsed.hostname or not parsed.port:
698
- print("⚠️ [Sentience] Proxy URL must include hostname and port")
699
- print(" Expected format: http://username:password@host:port")
745
+ logger.warning(
746
+ "Proxy URL must include hostname and port. Expected format: http://username:password@host:port"
747
+ )
700
748
  return None
701
749
 
702
750
  # Build server URL
@@ -710,8 +758,9 @@ class AsyncSentienceBrowser:
710
758
  )
711
759
 
712
760
  except Exception as e:
713
- print(f"⚠️ [Sentience] Invalid proxy configuration: {e}")
714
- print(" Expected format: http://username:password@host:port")
761
+ logger.warning(
762
+ f"Invalid proxy configuration: {e}. Expected format: http://username:password@host:port"
763
+ )
715
764
  return None
716
765
 
717
766
  async def start(self) -> None:
@@ -730,12 +779,40 @@ class AsyncSentienceBrowser:
730
779
  f"--disable-extensions-except={self._extension_path}",
731
780
  f"--load-extension={self._extension_path}",
732
781
  "--disable-blink-features=AutomationControlled",
733
- "--no-sandbox",
734
782
  "--disable-infobars",
735
783
  "--disable-features=WebRtcHideLocalIpsWithMdns",
736
784
  "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
737
785
  ]
738
786
 
787
+ # Only add --no-sandbox on Linux (causes crashes on macOS)
788
+ # macOS sandboxing works fine and the flag actually causes crashes
789
+ if platform.system() == "Linux":
790
+ args.append("--no-sandbox")
791
+
792
+ # Add GPU-disabling flags for macOS to prevent Chrome for Testing crash-on-exit
793
+ # These flags help avoid EXC_BAD_ACCESS crashes during browser shutdown
794
+ if platform.system() == "Darwin": # macOS
795
+ args.extend(
796
+ [
797
+ "--disable-gpu",
798
+ "--disable-software-rasterizer",
799
+ "--disable-dev-shm-usage",
800
+ "--disable-breakpad", # Disable crash reporter to prevent macOS crash dialogs
801
+ "--disable-crash-reporter", # Disable crash reporter UI
802
+ "--disable-crash-handler", # Disable crash handler completely
803
+ "--disable-in-process-stack-traces", # Disable stack trace collection
804
+ "--disable-hang-monitor", # Disable hang detection
805
+ "--disable-background-networking", # Disable background networking
806
+ "--disable-background-timer-throttling", # Disable background throttling
807
+ "--disable-backgrounding-occluded-windows", # Disable backgrounding
808
+ "--disable-renderer-backgrounding", # Disable renderer backgrounding
809
+ "--disable-features=TranslateUI", # Disable translate UI
810
+ "--disable-ipc-flooding-protection", # Disable IPC flooding protection
811
+ "--disable-logging", # Disable logging to reduce stderr noise
812
+ "--log-level=3", # Set log level to fatal only (suppresses warnings)
813
+ ]
814
+ )
815
+
739
816
  if self.headless:
740
817
  args.append("--headless=new")
741
818
 
@@ -756,8 +833,16 @@ class AsyncSentienceBrowser:
756
833
  "args": args,
757
834
  "viewport": {"width": self.viewport.width, "height": self.viewport.height},
758
835
  "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
836
+ # Note: Don't set "channel" - let Playwright use its default managed Chromium
837
+ # Setting channel=None doesn't force bundled Chromium and can still pick Chrome for Testing
759
838
  }
760
839
 
840
+ # If executable_path is provided, use it to force specific Chromium binary
841
+ # This guarantees we use Chromium (not Chrome for Testing) on macOS
842
+ if self.executable_path:
843
+ launch_params["executable_path"] = self.executable_path
844
+ logger.info(f"Using explicit executable: {self.executable_path}")
845
+
761
846
  # Add device scale factor if configured
762
847
  if self.device_scale_factor is not None:
763
848
  launch_params["device_scale_factor"] = self.device_scale_factor
@@ -766,7 +851,7 @@ class AsyncSentienceBrowser:
766
851
  if proxy_config:
767
852
  launch_params["proxy"] = proxy_config.to_playwright_dict()
768
853
  launch_params["ignore_https_errors"] = True
769
- print(f"🌐 [Sentience] Using proxy: {proxy_config.server}")
854
+ logger.info(f"Using proxy: {proxy_config.server}")
770
855
 
771
856
  # Add video recording if configured
772
857
  if self.record_video_dir:
@@ -774,9 +859,8 @@ class AsyncSentienceBrowser:
774
859
  video_dir.mkdir(parents=True, exist_ok=True)
775
860
  launch_params["record_video_dir"] = str(video_dir)
776
861
  launch_params["record_video_size"] = self.record_video_size
777
- print(f"🎥 [Sentience] Recording video to: {video_dir}")
778
- print(
779
- f" Resolution: {self.record_video_size['width']}x{self.record_video_size['height']}"
862
+ logger.info(
863
+ f"Recording video to: {video_dir} (Resolution: {self.record_video_size['width']}x{self.record_video_size['height']})"
780
864
  )
781
865
 
782
866
  # Launch persistent context
@@ -867,7 +951,7 @@ class AsyncSentienceBrowser:
867
951
  playwright_cookies.append(playwright_cookie)
868
952
 
869
953
  await self.context.add_cookies(playwright_cookies)
870
- print(f"✅ [Sentience] Injected {len(state.cookies)} cookie(s)")
954
+ logger.debug(f"Injected {len(state.cookies)} cookie(s)")
871
955
 
872
956
  # Inject LocalStorage
873
957
  if state.origins:
@@ -891,11 +975,11 @@ class AsyncSentienceBrowser:
891
975
  }""",
892
976
  localStorage_dict,
893
977
  )
894
- print(
895
- f"✅ [Sentience] Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
978
+ logger.debug(
979
+ f"Injected {len(origin_data.localStorage)} localStorage item(s) for {origin}"
896
980
  )
897
981
  except Exception as e:
898
- print(f"⚠️ [Sentience] Failed to inject localStorage for {origin}: {e}")
982
+ logger.warning(f"Failed to inject localStorage for {origin}: {e}")
899
983
 
900
984
  async def _wait_for_extension(self, timeout_sec: float = 5.0) -> bool:
901
985
  """Poll for window.sentience to be available (async)"""
@@ -933,7 +1017,7 @@ class AsyncSentienceBrowser:
933
1017
 
934
1018
  return False
935
1019
 
936
- async def close(self, output_path: str | Path | None = None) -> str | None:
1020
+ async def close(self, output_path: str | Path | None = None) -> tuple[str | None, bool]:
937
1021
  """
938
1022
  Close browser and cleanup (async)
939
1023
 
@@ -941,29 +1025,88 @@ class AsyncSentienceBrowser:
941
1025
  output_path: Optional path to rename the video file to
942
1026
 
943
1027
  Returns:
944
- Path to video file if recording was enabled, None otherwise
1028
+ Tuple of (video_path, shutdown_clean)
1029
+ - video_path: Path to video file if recording was enabled, None otherwise
1030
+ - shutdown_clean: True if shutdown completed without errors, False if there were issues
1031
+
1032
+ Note: Video path is resolved AFTER context close to avoid touching video
1033
+ subsystem during teardown, which can cause crashes on macOS.
945
1034
  """
946
- temp_video_path = None
1035
+ # CRITICAL: Don't access page.video.path() BEFORE closing context
1036
+ # This can poke the video subsystem at an awkward time and cause crashes
1037
+ # Instead, we'll locate the video file after context closes
1038
+
1039
+ # CRITICAL: Wait before closing to ensure all operations are complete
1040
+ # This is especially important for video recording - we need to ensure
1041
+ # all frames are written and the encoder is ready to finalize
1042
+ if platform.system() == "Darwin": # macOS
1043
+ # On macOS, give extra time for video encoder to finish writing frames
1044
+ # 4K video recording needs more time to flush buffers
1045
+ logger.debug("Waiting for video recording to stabilize before closing (macOS)...")
1046
+ await asyncio.sleep(2.0)
1047
+ else:
1048
+ await asyncio.sleep(1.0)
947
1049
 
948
- if self.record_video_dir:
1050
+ # Graceful shutdown: close context first, then playwright
1051
+ # Use longer timeouts on macOS where video finalization can take longer
1052
+ context_close_success = True
1053
+ if self.context:
1054
+ try:
1055
+ # Give context time to close gracefully (especially for video finalization)
1056
+ # Increased timeout for macOS where 4K video finalization can take longer
1057
+ await asyncio.wait_for(self.context.close(), timeout=30.0)
1058
+ logger.debug("Context closed successfully")
1059
+ except TimeoutError:
1060
+ logger.warning("Context close timed out, continuing with cleanup...")
1061
+ context_close_success = False
1062
+ except Exception as e:
1063
+ logger.warning(f"Error closing context: {e}")
1064
+ context_close_success = False
1065
+ finally:
1066
+ self.context = None
1067
+
1068
+ # Give Chrome a moment to fully flush video + release resources
1069
+ # This avoids stopping the driver while the browser is still finishing the .webm write/encoder shutdown
1070
+ # Increased grace period on macOS to allow more time for process cleanup
1071
+ grace_period = 2.0 if platform.system() == "Darwin" else 1.0
1072
+ await asyncio.sleep(grace_period)
1073
+
1074
+ playwright_stop_success = True
1075
+ if self.playwright:
949
1076
  try:
950
- if self.page and self.page.video:
951
- temp_video_path = await self.page.video.path()
952
- elif self.context:
953
- for page in self.context.pages:
954
- if page.video:
955
- temp_video_path = await page.video.path()
956
- break
957
- except Exception:
958
- pass
1077
+ # Give playwright time to stop gracefully
1078
+ # Increased timeout to match context close timeout
1079
+ await asyncio.wait_for(self.playwright.stop(), timeout=15.0)
1080
+ logger.debug("Playwright stopped successfully")
1081
+ except TimeoutError:
1082
+ logger.warning("Playwright stop timed out, continuing with cleanup...")
1083
+ playwright_stop_success = False
1084
+ except Exception as e:
1085
+ logger.warning(f"Error stopping playwright: {e}")
1086
+ playwright_stop_success = False
1087
+ finally:
1088
+ self.playwright = None
959
1089
 
960
- if self.context:
961
- await self.context.close()
962
- self.context = None
1090
+ # Additional cleanup: On macOS, wait a bit more to ensure all browser processes are terminated
1091
+ # This helps prevent crash dialogs from appearing
1092
+ if platform.system() == "Darwin":
1093
+ await asyncio.sleep(0.5)
963
1094
 
964
- if self.playwright:
965
- await self.playwright.stop()
966
- self.playwright = None
1095
+ # NOW resolve video path after context is closed and video is finalized
1096
+ temp_video_path = None
1097
+ if self.record_video_dir:
1098
+ try:
1099
+ # Locate the newest .webm file in record_video_dir
1100
+ # This avoids touching page.video during teardown
1101
+ video_dir = Path(self.record_video_dir)
1102
+ if video_dir.exists():
1103
+ webm_files = list(video_dir.glob("*.webm"))
1104
+ if webm_files:
1105
+ # Get the most recently modified file
1106
+ temp_video_path = max(webm_files, key=lambda p: p.stat().st_mtime)
1107
+ logger.debug(f"Found video file: {temp_video_path}")
1108
+ except Exception as e:
1109
+ logger.warning(f"Could not locate video file: {e}")
967
1110
 
968
1111
  if self._extension_path and os.path.exists(self._extension_path):
969
1112
  shutil.rmtree(self._extension_path)
@@ -984,7 +1127,19 @@ class AsyncSentienceBrowser:
984
1127
  warnings.warn(f"Failed to rename video file: {e}")
985
1128
  final_path = temp_video_path
986
1129
 
987
- return final_path
1130
+ # Log shutdown status (useful for detecting crashes in headless mode)
1131
+ shutdown_clean = context_close_success and playwright_stop_success
1132
+ if not shutdown_clean:
1133
+ logger.warning(
1134
+ f"Browser shutdown had issues - may indicate a crash "
1135
+ f"(context_close: {context_close_success}, playwright_stop: {playwright_stop_success})"
1136
+ )
1137
+ else:
1138
+ logger.debug("Browser shutdown completed cleanly")
1139
+
1140
+ # Return tuple: (video_path, shutdown_clean)
1141
+ # This allows callers to detect crashes even in headless mode
1142
+ return (final_path, shutdown_clean)
988
1143
 
989
1144
  async def __aenter__(self):
990
1145
  """Async context manager entry"""
@@ -993,6 +1148,7 @@ class AsyncSentienceBrowser:
993
1148
 
994
1149
  async def __aexit__(self, exc_type, exc_val, exc_tb):
995
1150
  """Async context manager exit"""
1151
+ # Ignore return value in context manager exit
996
1152
  await self.close()
997
1153
 
998
1154
  @classmethod