sentienceapi 0.95.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 (82) hide show
  1. sentience/__init__.py +253 -0
  2. sentience/_extension_loader.py +195 -0
  3. sentience/action_executor.py +215 -0
  4. sentience/actions.py +1020 -0
  5. sentience/agent.py +1181 -0
  6. sentience/agent_config.py +46 -0
  7. sentience/agent_runtime.py +424 -0
  8. sentience/asserts/__init__.py +70 -0
  9. sentience/asserts/expect.py +621 -0
  10. sentience/asserts/query.py +383 -0
  11. sentience/async_api.py +108 -0
  12. sentience/backends/__init__.py +137 -0
  13. sentience/backends/actions.py +343 -0
  14. sentience/backends/browser_use_adapter.py +241 -0
  15. sentience/backends/cdp_backend.py +393 -0
  16. sentience/backends/exceptions.py +211 -0
  17. sentience/backends/playwright_backend.py +194 -0
  18. sentience/backends/protocol.py +216 -0
  19. sentience/backends/sentience_context.py +469 -0
  20. sentience/backends/snapshot.py +427 -0
  21. sentience/base_agent.py +196 -0
  22. sentience/browser.py +1215 -0
  23. sentience/browser_evaluator.py +299 -0
  24. sentience/canonicalization.py +207 -0
  25. sentience/cli.py +130 -0
  26. sentience/cloud_tracing.py +807 -0
  27. sentience/constants.py +6 -0
  28. sentience/conversational_agent.py +543 -0
  29. sentience/element_filter.py +136 -0
  30. sentience/expect.py +188 -0
  31. sentience/extension/background.js +104 -0
  32. sentience/extension/content.js +161 -0
  33. sentience/extension/injected_api.js +914 -0
  34. sentience/extension/manifest.json +36 -0
  35. sentience/extension/pkg/sentience_core.d.ts +51 -0
  36. sentience/extension/pkg/sentience_core.js +323 -0
  37. sentience/extension/pkg/sentience_core_bg.wasm +0 -0
  38. sentience/extension/pkg/sentience_core_bg.wasm.d.ts +10 -0
  39. sentience/extension/release.json +115 -0
  40. sentience/formatting.py +15 -0
  41. sentience/generator.py +202 -0
  42. sentience/inspector.py +367 -0
  43. sentience/llm_interaction_handler.py +191 -0
  44. sentience/llm_provider.py +875 -0
  45. sentience/llm_provider_utils.py +120 -0
  46. sentience/llm_response_builder.py +153 -0
  47. sentience/models.py +846 -0
  48. sentience/ordinal.py +280 -0
  49. sentience/overlay.py +222 -0
  50. sentience/protocols.py +228 -0
  51. sentience/query.py +303 -0
  52. sentience/read.py +188 -0
  53. sentience/recorder.py +589 -0
  54. sentience/schemas/trace_v1.json +335 -0
  55. sentience/screenshot.py +100 -0
  56. sentience/sentience_methods.py +86 -0
  57. sentience/snapshot.py +706 -0
  58. sentience/snapshot_diff.py +126 -0
  59. sentience/text_search.py +262 -0
  60. sentience/trace_event_builder.py +148 -0
  61. sentience/trace_file_manager.py +197 -0
  62. sentience/trace_indexing/__init__.py +27 -0
  63. sentience/trace_indexing/index_schema.py +199 -0
  64. sentience/trace_indexing/indexer.py +414 -0
  65. sentience/tracer_factory.py +322 -0
  66. sentience/tracing.py +449 -0
  67. sentience/utils/__init__.py +40 -0
  68. sentience/utils/browser.py +46 -0
  69. sentience/utils/element.py +257 -0
  70. sentience/utils/formatting.py +59 -0
  71. sentience/utils.py +296 -0
  72. sentience/verification.py +380 -0
  73. sentience/visual_agent.py +2058 -0
  74. sentience/wait.py +139 -0
  75. sentienceapi-0.95.0.dist-info/METADATA +984 -0
  76. sentienceapi-0.95.0.dist-info/RECORD +82 -0
  77. sentienceapi-0.95.0.dist-info/WHEEL +5 -0
  78. sentienceapi-0.95.0.dist-info/entry_points.txt +2 -0
  79. sentienceapi-0.95.0.dist-info/licenses/LICENSE +24 -0
  80. sentienceapi-0.95.0.dist-info/licenses/LICENSE-APACHE +201 -0
  81. sentienceapi-0.95.0.dist-info/licenses/LICENSE-MIT +21 -0
  82. sentienceapi-0.95.0.dist-info/top_level.txt +1 -0
sentience/recorder.py ADDED
@@ -0,0 +1,589 @@
1
+ """
2
+ Recorder - captures user actions into a trace
3
+ """
4
+
5
+ import json
6
+ from datetime import datetime
7
+ from typing import Any, Optional
8
+
9
+ from .browser import AsyncSentienceBrowser, SentienceBrowser
10
+ from .models import Element, Snapshot
11
+ from .snapshot import snapshot, snapshot_async
12
+
13
+
14
+ class TraceStep:
15
+ """A single step in a trace"""
16
+
17
+ def __init__(
18
+ self,
19
+ ts: int,
20
+ type: str,
21
+ selector: str | None = None,
22
+ element_id: int | None = None,
23
+ text: str | None = None,
24
+ key: str | None = None,
25
+ url: str | None = None,
26
+ snapshot: Snapshot | None = None,
27
+ ):
28
+ self.ts = ts
29
+ self.type = type
30
+ self.selector = selector
31
+ self.element_id = element_id
32
+ self.text = text
33
+ self.key = key
34
+ self.url = url
35
+ self.snapshot = snapshot
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Convert to dictionary for JSON serialization"""
39
+ result = {
40
+ "ts": self.ts,
41
+ "type": self.type,
42
+ }
43
+ if self.selector is not None:
44
+ result["selector"] = self.selector
45
+ if self.element_id is not None:
46
+ result["element_id"] = self.element_id
47
+ if self.text is not None:
48
+ result["text"] = self.text
49
+ if self.key is not None:
50
+ result["key"] = self.key
51
+ if self.url is not None:
52
+ result["url"] = self.url
53
+ if self.snapshot is not None:
54
+ result["snapshot"] = self.snapshot.model_dump()
55
+ return result
56
+
57
+
58
+ class Trace:
59
+ """Trace of user actions"""
60
+
61
+ def __init__(self, start_url: str):
62
+ self.version = "1.0.0"
63
+ self.created_at = datetime.now().isoformat()
64
+ self.start_url = start_url
65
+ self.steps: list[TraceStep] = []
66
+ self._start_time = datetime.now()
67
+
68
+ def add_step(self, step: TraceStep) -> None:
69
+ """Add a step to the trace"""
70
+ self.steps.append(step)
71
+
72
+ def add_navigation(self, url: str) -> None:
73
+ """Add navigation step"""
74
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
75
+ step = TraceStep(ts=ts, type="navigation", url=url)
76
+ self.add_step(step)
77
+
78
+ def add_click(self, element_id: int, selector: str | None = None) -> None:
79
+ """Add click step"""
80
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
81
+ step = TraceStep(ts=ts, type="click", element_id=element_id, selector=selector)
82
+ self.add_step(step)
83
+
84
+ def add_type(
85
+ self,
86
+ element_id: int,
87
+ text: str,
88
+ selector: str | None = None,
89
+ mask: bool = False,
90
+ ) -> None:
91
+ """Add type step"""
92
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
93
+ # Mask sensitive data if requested
94
+ masked_text = "***" if mask else text
95
+ step = TraceStep(
96
+ ts=ts,
97
+ type="type",
98
+ element_id=element_id,
99
+ text=masked_text,
100
+ selector=selector,
101
+ )
102
+ self.add_step(step)
103
+
104
+ def add_press(self, key: str) -> None:
105
+ """Add press key step"""
106
+ ts = int((datetime.now() - self._start_time).total_seconds() * 1000)
107
+ step = TraceStep(ts=ts, type="press", key=key)
108
+ self.add_step(step)
109
+
110
+ def save(self, filepath: str) -> None:
111
+ """Save trace to JSON file"""
112
+ data = {
113
+ "version": self.version,
114
+ "created_at": self.created_at,
115
+ "start_url": self.start_url,
116
+ "steps": [step.to_dict() for step in self.steps],
117
+ }
118
+ with open(filepath, "w") as f:
119
+ json.dump(data, f, indent=2)
120
+
121
+ @classmethod
122
+ def load(cls, filepath: str) -> "Trace":
123
+ """Load trace from JSON file"""
124
+ with open(filepath) as f:
125
+ data = json.load(f)
126
+
127
+ trace = cls(data["start_url"])
128
+ trace.version = data["version"]
129
+ trace.created_at = data["created_at"]
130
+
131
+ for step_data in data["steps"]:
132
+ snapshot_data = step_data.get("snapshot")
133
+ snapshot_obj = None
134
+ if snapshot_data:
135
+ snapshot_obj = Snapshot(**snapshot_data)
136
+
137
+ step = TraceStep(
138
+ ts=step_data["ts"],
139
+ type=step_data["type"],
140
+ selector=step_data.get("selector"),
141
+ element_id=step_data.get("element_id"),
142
+ text=step_data.get("text"),
143
+ key=step_data.get("key"),
144
+ url=step_data.get("url"),
145
+ snapshot=snapshot_obj,
146
+ )
147
+ trace.steps.append(step)
148
+
149
+ return trace
150
+
151
+
152
+ class Recorder:
153
+ """Recorder for capturing user actions"""
154
+
155
+ def __init__(self, browser: SentienceBrowser, capture_snapshots: bool = False):
156
+ self.browser = browser
157
+ self.capture_snapshots = capture_snapshots
158
+ self.trace: Trace | None = None
159
+ self._active = False
160
+ self._mask_patterns: list[str] = [] # Patterns to mask (e.g., "password", "email")
161
+
162
+ def start(self) -> None:
163
+ """Start recording"""
164
+ if not self.browser.page:
165
+ raise RuntimeError("Browser not started. Call browser.start() first.")
166
+
167
+ self._active = True
168
+ start_url = self.browser.page.url
169
+ self.trace = Trace(start_url)
170
+
171
+ # Set up event listeners in the browser
172
+ self._setup_listeners()
173
+
174
+ def stop(self) -> None:
175
+ """Stop recording"""
176
+ self._active = False
177
+ self._cleanup_listeners()
178
+
179
+ def add_mask_pattern(self, pattern: str) -> None:
180
+ """Add a pattern to mask in recorded text (e.g., "password", "email")"""
181
+ self._mask_patterns.append(pattern.lower())
182
+
183
+ def _should_mask(self, text: str) -> bool:
184
+ """Check if text should be masked"""
185
+ text_lower = text.lower()
186
+ return any(pattern in text_lower for pattern in self._mask_patterns)
187
+
188
+ def _setup_listeners(self) -> None:
189
+ """Set up event listeners to capture actions"""
190
+ # Note: We'll capture actions through the SDK methods rather than DOM events
191
+ # This is cleaner and more reliable
192
+ pass
193
+
194
+ def _cleanup_listeners(self) -> None:
195
+ """Clean up event listeners"""
196
+ pass
197
+
198
+ def _infer_selector(self, element_id: int) -> str | None: # noqa: C901
199
+ """
200
+ Infer a semantic selector for an element
201
+
202
+ Uses heuristics to build a robust selector:
203
+ - role=... text~"..."
204
+ - If text empty: use name/aria-label/placeholder
205
+ - Include clickable=true when relevant
206
+ - Validate against snapshot (should match 1 element)
207
+ """
208
+ try:
209
+ # Take a snapshot to get element info
210
+ snap = snapshot(self.browser)
211
+
212
+ # Find the element in the snapshot
213
+ element = None
214
+ for el in snap.elements:
215
+ if el.id == element_id:
216
+ element = el
217
+ break
218
+
219
+ if not element:
220
+ return None
221
+
222
+ # Build candidate selector
223
+ parts = []
224
+
225
+ # Add role
226
+ if element.role and element.role != "generic":
227
+ parts.append(f"role={element.role}")
228
+
229
+ # Add text if available
230
+ if element.text:
231
+ # Use contains match for text
232
+ text_part = element.text.replace('"', '\\"')[:50] # Limit length
233
+ parts.append(f'text~"{text_part}"')
234
+ else:
235
+ # Try to get name/aria-label/placeholder from DOM
236
+ try:
237
+ el = self.browser.page.evaluate(
238
+ f"""
239
+ () => {{
240
+ const el = window.sentience_registry[{element_id}];
241
+ if (!el) return null;
242
+ return {{
243
+ name: el.name || null,
244
+ ariaLabel: el.getAttribute('aria-label') || null,
245
+ placeholder: el.placeholder || null
246
+ }};
247
+ }}
248
+ """
249
+ )
250
+
251
+ if el:
252
+ if el.get("name"):
253
+ parts.append(f'name="{el["name"]}"')
254
+ elif el.get("ariaLabel"):
255
+ parts.append(f'text~"{el["ariaLabel"]}"')
256
+ elif el.get("placeholder"):
257
+ parts.append(f'text~"{el["placeholder"]}"')
258
+ except Exception:
259
+ pass
260
+
261
+ # Add clickable if relevant
262
+ if element.visual_cues.is_clickable:
263
+ parts.append("clickable=true")
264
+
265
+ if not parts:
266
+ return None
267
+
268
+ selector = " ".join(parts)
269
+
270
+ # Validate selector - should match exactly 1 element
271
+ matches = [el for el in snap.elements if self._match_element(el, selector)]
272
+
273
+ if len(matches) == 1:
274
+ return selector
275
+ elif len(matches) > 1:
276
+ # Add more constraints (importance threshold, near-center)
277
+ # For now, just return the selector with a note
278
+ return selector
279
+ else:
280
+ # Selector doesn't match - return None (will use element_id)
281
+ return None
282
+
283
+ except Exception:
284
+ return None
285
+
286
+ def _match_element(self, element: Element, selector: str) -> bool:
287
+ """Simple selector matching (basic implementation)"""
288
+ # This is a simplified version - in production, use the full query engine
289
+ from .query import match_element, parse_selector
290
+
291
+ try:
292
+ query_dict = parse_selector(selector)
293
+ return match_element(element, query_dict)
294
+ except Exception:
295
+ return False
296
+
297
+ def record_navigation(self, url: str) -> None:
298
+ """Record a navigation event"""
299
+ if self._active and self.trace:
300
+ self.trace.add_navigation(url)
301
+
302
+ def record_click(self, element_id: int, selector: str | None = None) -> None:
303
+ """Record a click event with smart selector inference"""
304
+ if self._active and self.trace:
305
+ # If no selector provided, try to infer one
306
+ if selector is None:
307
+ selector = self._infer_selector(element_id)
308
+
309
+ # Optionally capture snapshot
310
+ if self.capture_snapshots:
311
+ try:
312
+ snap = snapshot(self.browser)
313
+ step = TraceStep(
314
+ ts=int((datetime.now() - self.trace._start_time).total_seconds() * 1000),
315
+ type="click",
316
+ element_id=element_id,
317
+ selector=selector,
318
+ snapshot=snap,
319
+ )
320
+ self.trace.add_step(step)
321
+ except Exception:
322
+ # If snapshot fails, just record without it
323
+ self.trace.add_click(element_id, selector)
324
+ else:
325
+ self.trace.add_click(element_id, selector)
326
+
327
+ def record_type(self, element_id: int, text: str, selector: str | None = None) -> None:
328
+ """Record a type event with smart selector inference"""
329
+ if self._active and self.trace:
330
+ # If no selector provided, try to infer one
331
+ if selector is None:
332
+ selector = self._infer_selector(element_id)
333
+
334
+ mask = self._should_mask(text)
335
+ self.trace.add_type(element_id, text, selector, mask=mask)
336
+
337
+ def record_press(self, key: str) -> None:
338
+ """Record a key press event"""
339
+ if self._active and self.trace:
340
+ self.trace.add_press(key)
341
+
342
+ def save(self, filepath: str) -> None:
343
+ """Save trace to file"""
344
+ if not self.trace:
345
+ raise RuntimeError("No trace to save. Start recording first.")
346
+ self.trace.save(filepath)
347
+
348
+ def __enter__(self):
349
+ """Context manager entry"""
350
+ self.start()
351
+ return self
352
+
353
+ def __exit__(self, exc_type, exc_val, exc_tb):
354
+ """Context manager exit"""
355
+ self.stop()
356
+
357
+
358
+ def record(browser: SentienceBrowser, capture_snapshots: bool = False) -> Recorder:
359
+ """
360
+ Create a recorder instance
361
+
362
+ Args:
363
+ browser: SentienceBrowser instance
364
+ capture_snapshots: Whether to capture snapshots at each step
365
+
366
+ Returns:
367
+ Recorder instance
368
+ """
369
+ return Recorder(browser, capture_snapshots=capture_snapshots)
370
+
371
+
372
+ class RecorderAsync:
373
+ """Recorder for capturing user actions (async)"""
374
+
375
+ def __init__(self, browser: AsyncSentienceBrowser, capture_snapshots: bool = False):
376
+ self.browser = browser
377
+ self.capture_snapshots = capture_snapshots
378
+ self.trace: Trace | None = None
379
+ self._active = False
380
+ self._mask_patterns: list[str] = [] # Patterns to mask (e.g., "password", "email")
381
+
382
+ async def start(self) -> None:
383
+ """Start recording"""
384
+ if not self.browser.page:
385
+ raise RuntimeError("Browser not started. Call await browser.start() first.")
386
+
387
+ self._active = True
388
+ start_url = self.browser.page.url
389
+ self.trace = Trace(start_url)
390
+
391
+ # Set up event listeners in the browser
392
+ self._setup_listeners()
393
+
394
+ def stop(self) -> None:
395
+ """Stop recording"""
396
+ self._active = False
397
+ self._cleanup_listeners()
398
+
399
+ def add_mask_pattern(self, pattern: str) -> None:
400
+ """Add a pattern to mask in recorded text (e.g., "password", "email")"""
401
+ self._mask_patterns.append(pattern.lower())
402
+
403
+ def _should_mask(self, text: str) -> bool:
404
+ """Check if text should be masked"""
405
+ text_lower = text.lower()
406
+ return any(pattern in text_lower for pattern in self._mask_patterns)
407
+
408
+ def _setup_listeners(self) -> None:
409
+ """Set up event listeners to capture actions"""
410
+ # Note: We'll capture actions through the SDK methods rather than DOM events
411
+ # This is cleaner and more reliable
412
+ pass
413
+
414
+ def _cleanup_listeners(self) -> None:
415
+ """Clean up event listeners"""
416
+ pass
417
+
418
+ async def _infer_selector(self, element_id: int) -> str | None: # noqa: C901
419
+ """
420
+ Infer a semantic selector for an element (async)
421
+
422
+ Uses heuristics to build a robust selector:
423
+ - role=... text~"..."
424
+ - If text empty: use name/aria-label/placeholder
425
+ - Include clickable=true when relevant
426
+ - Validate against snapshot (should match 1 element)
427
+ """
428
+ try:
429
+ # Take a snapshot to get element info
430
+ snap = await snapshot_async(self.browser)
431
+
432
+ # Find the element in the snapshot
433
+ element = None
434
+ for el in snap.elements:
435
+ if el.id == element_id:
436
+ element = el
437
+ break
438
+
439
+ if not element:
440
+ return None
441
+
442
+ # Build candidate selector
443
+ parts = []
444
+
445
+ # Add role
446
+ if element.role and element.role != "generic":
447
+ parts.append(f"role={element.role}")
448
+
449
+ # Add text if available
450
+ if element.text:
451
+ # Use contains match for text
452
+ text_part = element.text.replace('"', '\\"')[:50] # Limit length
453
+ parts.append(f'text~"{text_part}"')
454
+ else:
455
+ # Try to get name/aria-label/placeholder from DOM
456
+ try:
457
+ el = await self.browser.page.evaluate(
458
+ f"""
459
+ () => {{
460
+ const el = window.sentience_registry[{element_id}];
461
+ if (!el) return null;
462
+ return {{
463
+ name: el.name || null,
464
+ ariaLabel: el.getAttribute('aria-label') || null,
465
+ placeholder: el.placeholder || null
466
+ }};
467
+ }}
468
+ """
469
+ )
470
+
471
+ if el:
472
+ if el.get("name"):
473
+ parts.append(f'name="{el["name"]}"')
474
+ elif el.get("ariaLabel"):
475
+ parts.append(f'text~"{el["ariaLabel"]}"')
476
+ elif el.get("placeholder"):
477
+ parts.append(f'text~"{el["placeholder"]}"')
478
+ except Exception:
479
+ pass
480
+
481
+ # Add clickable if relevant
482
+ if element.visual_cues.is_clickable:
483
+ parts.append("clickable=true")
484
+
485
+ if not parts:
486
+ return None
487
+
488
+ selector = " ".join(parts)
489
+
490
+ # Validate selector - should match exactly 1 element
491
+ matches = [el for el in snap.elements if self._match_element(el, selector)]
492
+
493
+ if len(matches) == 1:
494
+ return selector
495
+ elif len(matches) > 1:
496
+ # Add more constraints (importance threshold, near-center)
497
+ # For now, just return the selector with a note
498
+ return selector
499
+ else:
500
+ # Selector doesn't match - return None (will use element_id)
501
+ return None
502
+
503
+ except Exception:
504
+ return None
505
+
506
+ def _match_element(self, element: Element, selector: str) -> bool:
507
+ """Simple selector matching (basic implementation)"""
508
+ # This is a simplified version - in production, use the full query engine
509
+ from .query import match_element, parse_selector
510
+
511
+ try:
512
+ query_dict = parse_selector(selector)
513
+ return match_element(element, query_dict)
514
+ except Exception:
515
+ return False
516
+
517
+ def record_navigation(self, url: str) -> None:
518
+ """Record a navigation event"""
519
+ if self._active and self.trace:
520
+ self.trace.add_navigation(url)
521
+
522
+ async def record_click(self, element_id: int, selector: str | None = None) -> None:
523
+ """Record a click event with smart selector inference (async)"""
524
+ if self._active and self.trace:
525
+ # If no selector provided, try to infer one
526
+ if selector is None:
527
+ selector = await self._infer_selector(element_id)
528
+
529
+ # Optionally capture snapshot
530
+ if self.capture_snapshots:
531
+ try:
532
+ snap = await snapshot_async(self.browser)
533
+ step = TraceStep(
534
+ ts=int((datetime.now() - self.trace._start_time).total_seconds() * 1000),
535
+ type="click",
536
+ element_id=element_id,
537
+ selector=selector,
538
+ snapshot=snap,
539
+ )
540
+ self.trace.add_step(step)
541
+ except Exception:
542
+ # If snapshot fails, just record without it
543
+ self.trace.add_click(element_id, selector)
544
+ else:
545
+ self.trace.add_click(element_id, selector)
546
+
547
+ async def record_type(self, element_id: int, text: str, selector: str | None = None) -> None:
548
+ """Record a type event with smart selector inference (async)"""
549
+ if self._active and self.trace:
550
+ # If no selector provided, try to infer one
551
+ if selector is None:
552
+ selector = await self._infer_selector(element_id)
553
+
554
+ mask = self._should_mask(text)
555
+ self.trace.add_type(element_id, text, selector, mask=mask)
556
+
557
+ def record_press(self, key: str) -> None:
558
+ """Record a key press event"""
559
+ if self._active and self.trace:
560
+ self.trace.add_press(key)
561
+
562
+ def save(self, filepath: str) -> None:
563
+ """Save trace to file"""
564
+ if not self.trace:
565
+ raise RuntimeError("No trace to save. Start recording first.")
566
+ self.trace.save(filepath)
567
+
568
+ async def __aenter__(self):
569
+ """Context manager entry"""
570
+ await self.start()
571
+ return self
572
+
573
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
574
+ """Context manager exit"""
575
+ self.stop()
576
+
577
+
578
+ def record_async(browser: AsyncSentienceBrowser, capture_snapshots: bool = False) -> RecorderAsync:
579
+ """
580
+ Create a recorder instance (async)
581
+
582
+ Args:
583
+ browser: AsyncSentienceBrowser instance
584
+ capture_snapshots: Whether to capture snapshots at each step
585
+
586
+ Returns:
587
+ RecorderAsync instance
588
+ """
589
+ return RecorderAsync(browser, capture_snapshots=capture_snapshots)