cmdop 0.1.21__py3-none-any.whl → 0.1.23__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.
Files changed (36) hide show
  1. cmdop/__init__.py +1 -1
  2. cmdop/_generated/rpc_messages/browser_pb2.py +135 -85
  3. cmdop/_generated/rpc_messages/browser_pb2.pyi +270 -2
  4. cmdop/_generated/rpc_messages_pb2.pyi +25 -0
  5. cmdop/_generated/service_pb2.py +2 -2
  6. cmdop/_generated/service_pb2_grpc.py +345 -0
  7. cmdop/client.py +2 -8
  8. cmdop/services/browser/__init__.py +44 -31
  9. cmdop/services/browser/capabilities/__init__.py +17 -0
  10. cmdop/services/browser/capabilities/_base.py +28 -0
  11. cmdop/services/browser/capabilities/_helpers.py +16 -0
  12. cmdop/services/browser/capabilities/dom.py +76 -0
  13. cmdop/services/browser/capabilities/fetch.py +45 -0
  14. cmdop/services/browser/capabilities/input.py +49 -0
  15. cmdop/services/browser/capabilities/network.py +245 -0
  16. cmdop/services/browser/capabilities/scroll.py +147 -0
  17. cmdop/services/browser/capabilities/timing.py +66 -0
  18. cmdop/services/browser/js/__init__.py +6 -4
  19. cmdop/services/browser/js/interaction.py +34 -0
  20. cmdop/services/browser/models.py +103 -0
  21. cmdop/services/browser/service/__init__.py +5 -0
  22. cmdop/services/browser/service/aio.py +30 -0
  23. cmdop/services/browser/{sync/service.py → service/sync.py} +206 -6
  24. cmdop/services/browser/session.py +194 -0
  25. {cmdop-0.1.21.dist-info → cmdop-0.1.23.dist-info}/METADATA +107 -59
  26. {cmdop-0.1.21.dist-info → cmdop-0.1.23.dist-info}/RECORD +29 -24
  27. cmdop/services/browser/aio/__init__.py +0 -6
  28. cmdop/services/browser/aio/service.py +0 -420
  29. cmdop/services/browser/aio/session.py +0 -407
  30. cmdop/services/browser/base/__init__.py +0 -6
  31. cmdop/services/browser/base/session.py +0 -124
  32. cmdop/services/browser/sync/__init__.py +0 -6
  33. cmdop/services/browser/sync/session.py +0 -644
  34. /cmdop/services/browser/{base/service.py → service/_helpers.py} +0 -0
  35. {cmdop-0.1.21.dist-info → cmdop-0.1.23.dist-info}/WHEEL +0 -0
  36. {cmdop-0.1.21.dist-info → cmdop-0.1.23.dist-info}/licenses/LICENSE +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from enum import Enum
5
6
  from typing import Any
6
7
 
7
8
  from pydantic import BaseModel
@@ -14,6 +15,25 @@ from cmdop.exceptions import (
14
15
  )
15
16
 
16
17
 
18
+ class WaitUntil(str, Enum):
19
+ """Wait strategy for navigation.
20
+
21
+ Determines when navigation is considered complete.
22
+ """
23
+
24
+ LOAD = "load"
25
+ """Wait for load event (default, not recommended for SPA)."""
26
+
27
+ DOMCONTENTLOADED = "domcontentloaded"
28
+ """Wait for DOMContentLoaded event."""
29
+
30
+ NETWORKIDLE = "networkidle"
31
+ """Wait until no network activity for 500ms (best for SPA)."""
32
+
33
+ COMMIT = "commit"
34
+ """Return immediately after navigation commits (fastest)."""
35
+
36
+
17
37
  def raise_browser_error(error: str, operation: str, **context: Any) -> None:
18
38
  """Raise appropriate browser exception based on error message."""
19
39
  error_lower = error.lower()
@@ -115,3 +135,86 @@ class InfiniteScrollResult(BaseModel):
115
135
  at_bottom: bool = False
116
136
  total_seen: int = 0
117
137
  error: str | None = None
138
+
139
+
140
+ # =============================================================================
141
+ # Network Capture Models (v2.19.0)
142
+ # =============================================================================
143
+
144
+
145
+ class NetworkRequest(BaseModel):
146
+ """Captured HTTP request."""
147
+
148
+ url: str = ""
149
+ method: str = "GET"
150
+ headers: dict[str, str] = {}
151
+ body: bytes = b""
152
+ content_type: str = ""
153
+ resource_type: str = "" # document, script, xhr, fetch, image, etc.
154
+
155
+
156
+ class NetworkResponse(BaseModel):
157
+ """Captured HTTP response."""
158
+
159
+ status: int = 0
160
+ status_text: str = ""
161
+ headers: dict[str, str] = {}
162
+ body: bytes = b""
163
+ content_type: str = ""
164
+ size: int = 0
165
+ from_cache: bool = False
166
+
167
+
168
+ class NetworkTiming(BaseModel):
169
+ """Request timing data."""
170
+
171
+ started_at_ms: int = 0
172
+ ended_at_ms: int = 0
173
+ duration_ms: int = 0
174
+ wait_time_ms: int = 0 # Time to first byte
175
+ receive_time_ms: int = 0 # Time to download body
176
+
177
+
178
+ class NetworkExchange(BaseModel):
179
+ """Complete request/response exchange."""
180
+
181
+ id: str = ""
182
+ request: NetworkRequest = NetworkRequest()
183
+ response: NetworkResponse | None = None
184
+ timing: NetworkTiming = NetworkTiming()
185
+ error: str = ""
186
+ frame_id: str = ""
187
+ initiator: str = "" # URL or script that initiated
188
+
189
+ def json_body(self) -> Any:
190
+ """Parse response body as JSON."""
191
+ import json
192
+ if self.response and self.response.body:
193
+ return json.loads(self.response.body)
194
+ return None
195
+
196
+ def text_body(self) -> str:
197
+ """Get response body as text."""
198
+ if self.response and self.response.body:
199
+ return self.response.body.decode("utf-8", errors="replace")
200
+ return ""
201
+
202
+
203
+ class NetworkStats(BaseModel):
204
+ """Network capture statistics."""
205
+
206
+ enabled: bool = False
207
+ total_captured: int = 0
208
+ total_errors: int = 0
209
+ total_bytes: int = 0
210
+ average_duration_ms: int = 0
211
+
212
+
213
+ class NetworkFilter(BaseModel):
214
+ """Filter for querying captured exchanges."""
215
+
216
+ url_pattern: str = ""
217
+ methods: list[str] = []
218
+ status_codes: list[int] = []
219
+ resource_types: list[str] = []
220
+ limit: int = 0
@@ -0,0 +1,5 @@
1
+ """Browser service layer (gRPC)."""
2
+
3
+ from .sync import BrowserService
4
+
5
+ __all__ = ["BrowserService"]
@@ -0,0 +1,30 @@
1
+ """Async browser service stub.
2
+
3
+ Async browser is not implemented yet. Use sync BrowserService instead.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, NoReturn
9
+
10
+ if TYPE_CHECKING:
11
+ from cmdop.transport.base import BaseTransport
12
+
13
+
14
+ class AsyncBrowserService:
15
+ """
16
+ Async browser service stub.
17
+
18
+ Not implemented yet. Use sync CMDOPClient.browser instead.
19
+ """
20
+
21
+ def __init__(self, transport: BaseTransport) -> None:
22
+ self._transport = transport
23
+
24
+ def _not_implemented(self) -> NoReturn:
25
+ raise NotImplementedError(
26
+ "Async browser is not implemented. Use sync CMDOPClient.browser instead."
27
+ )
28
+
29
+ async def create_session(self, *args, **kwargs) -> NoReturn:
30
+ self._not_implemented()
@@ -6,8 +6,8 @@ import json
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from cmdop.services.base import BaseService
9
- from cmdop.services.browser.base.service import BaseServiceMixin, cookie_to_pb, pb_to_cookie
10
- from cmdop.services.browser.models import BrowserCookie, BrowserState, PageInfo, raise_browser_error
9
+ from cmdop.services.browser.service._helpers import BaseServiceMixin, cookie_to_pb, pb_to_cookie
10
+ from cmdop.services.browser.models import BrowserCookie, BrowserState, PageInfo, WaitUntil, raise_browser_error
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from cmdop.transport.base import BaseTransport
@@ -47,7 +47,7 @@ class BrowserService(BaseService, BaseServiceMixin):
47
47
  height: int = 800,
48
48
  ) -> "BrowserSession":
49
49
  from cmdop._generated.rpc_messages.browser_pb2 import BrowserCreateSessionRequest
50
- from cmdop.services.browser.sync.session import BrowserSession
50
+ from cmdop.services.browser.session import BrowserSession
51
51
 
52
52
  request = BrowserCreateSessionRequest(
53
53
  provider=provider,
@@ -75,11 +75,31 @@ class BrowserService(BaseService, BaseServiceMixin):
75
75
 
76
76
  # === Navigation & Interaction ===
77
77
 
78
- def navigate(self, session_id: str, url: str, timeout_ms: int = 30000) -> str:
79
- from cmdop._generated.rpc_messages.browser_pb2 import BrowserNavigateRequest
78
+ def navigate(
79
+ self,
80
+ session_id: str,
81
+ url: str,
82
+ timeout_ms: int = 30000,
83
+ wait_until: WaitUntil = WaitUntil.LOAD,
84
+ ) -> str:
85
+ from cmdop._generated.rpc_messages.browser_pb2 import (
86
+ BrowserNavigateRequest,
87
+ WaitUntil as PbWaitUntil,
88
+ )
89
+
90
+ # Convert Python enum to proto enum
91
+ pb_wait_until = {
92
+ WaitUntil.LOAD: PbWaitUntil.WAIT_LOAD,
93
+ WaitUntil.DOMCONTENTLOADED: PbWaitUntil.WAIT_DOMCONTENTLOADED,
94
+ WaitUntil.NETWORKIDLE: PbWaitUntil.WAIT_NETWORKIDLE,
95
+ WaitUntil.COMMIT: PbWaitUntil.WAIT_COMMIT,
96
+ }.get(wait_until, PbWaitUntil.WAIT_LOAD)
80
97
 
81
98
  request = BrowserNavigateRequest(
82
- browser_session_id=session_id, url=url, timeout_ms=timeout_ms
99
+ browser_session_id=session_id,
100
+ url=url,
101
+ timeout_ms=timeout_ms,
102
+ wait_until=pb_wait_until,
83
103
  )
84
104
  response = self._call_sync(self._get_stub.BrowserNavigate, request)
85
105
 
@@ -434,3 +454,183 @@ class BrowserService(BaseService, BaseServiceMixin):
434
454
  "items": json.loads(response.items_json) if response.items_json else [],
435
455
  "count": response.count,
436
456
  }
457
+
458
+ # === Network Capture (v2.19.0) ===
459
+
460
+ def network_enable(
461
+ self, session_id: str, max_exchanges: int = 1000, max_response_size: int = 10_000_000
462
+ ) -> None:
463
+ """Enable network capture."""
464
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkEnableRequest
465
+
466
+ request = BrowserNetworkEnableRequest(
467
+ browser_session_id=session_id,
468
+ max_exchanges=max_exchanges,
469
+ max_response_size=max_response_size,
470
+ )
471
+ response = self._call_sync(self._get_stub.BrowserNetworkEnable, request)
472
+
473
+ if not response.success:
474
+ raise RuntimeError(f"NetworkEnable failed: {response.error}")
475
+
476
+ def network_disable(self, session_id: str) -> None:
477
+ """Disable network capture."""
478
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkDisableRequest
479
+
480
+ request = BrowserNetworkDisableRequest(browser_session_id=session_id)
481
+ response = self._call_sync(self._get_stub.BrowserNetworkDisable, request)
482
+
483
+ if not response.success:
484
+ raise RuntimeError(f"NetworkDisable failed: {response.error}")
485
+
486
+ def network_get_exchanges(
487
+ self,
488
+ session_id: str,
489
+ url_pattern: str = "",
490
+ methods: list[str] | None = None,
491
+ status_codes: list[int] | None = None,
492
+ resource_types: list[str] | None = None,
493
+ limit: int = 0,
494
+ ) -> dict:
495
+ """Get captured exchanges."""
496
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkGetExchangesRequest
497
+
498
+ request = BrowserNetworkGetExchangesRequest(
499
+ browser_session_id=session_id,
500
+ url_pattern=url_pattern,
501
+ methods=methods or [],
502
+ status_codes=status_codes or [],
503
+ resource_types=resource_types or [],
504
+ limit=limit,
505
+ )
506
+ response = self._call_sync(self._get_stub.BrowserNetworkGetExchanges, request)
507
+
508
+ if not response.success:
509
+ raise RuntimeError(f"NetworkGetExchanges failed: {response.error}")
510
+
511
+ return {
512
+ "exchanges": [self._pb_to_exchange(e) for e in response.exchanges],
513
+ "count": response.count,
514
+ }
515
+
516
+ def network_get_exchange(self, session_id: str, exchange_id: str) -> dict:
517
+ """Get specific exchange by ID."""
518
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkGetExchangeRequest
519
+
520
+ request = BrowserNetworkGetExchangeRequest(
521
+ browser_session_id=session_id, exchange_id=exchange_id
522
+ )
523
+ response = self._call_sync(self._get_stub.BrowserNetworkGetExchange, request)
524
+
525
+ if not response.success:
526
+ raise RuntimeError(f"NetworkGetExchange failed: {response.error}")
527
+
528
+ return {"exchange": self._pb_to_exchange(response.exchange) if response.exchange else None}
529
+
530
+ def network_get_last(self, session_id: str, url_pattern: str = "") -> dict:
531
+ """Get most recent exchange matching URL pattern."""
532
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkGetLastRequest
533
+
534
+ request = BrowserNetworkGetLastRequest(
535
+ browser_session_id=session_id, url_pattern=url_pattern
536
+ )
537
+ response = self._call_sync(self._get_stub.BrowserNetworkGetLast, request)
538
+
539
+ if not response.success:
540
+ raise RuntimeError(f"NetworkGetLast failed: {response.error}")
541
+
542
+ return {"exchange": self._pb_to_exchange(response.exchange) if response.exchange else None}
543
+
544
+ def network_clear(self, session_id: str) -> None:
545
+ """Clear all captured exchanges."""
546
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkClearRequest
547
+
548
+ request = BrowserNetworkClearRequest(browser_session_id=session_id)
549
+ response = self._call_sync(self._get_stub.BrowserNetworkClear, request)
550
+
551
+ if not response.success:
552
+ raise RuntimeError(f"NetworkClear failed: {response.error}")
553
+
554
+ def network_stats(self, session_id: str) -> dict:
555
+ """Get capture statistics."""
556
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkStatsRequest
557
+
558
+ request = BrowserNetworkStatsRequest(browser_session_id=session_id)
559
+ response = self._call_sync(self._get_stub.BrowserNetworkStats, request)
560
+
561
+ if not response.success:
562
+ raise RuntimeError(f"NetworkStats failed: {response.error}")
563
+
564
+ return {
565
+ "enabled": response.enabled,
566
+ "total_captured": response.total_captured,
567
+ "total_errors": response.total_errors,
568
+ "total_bytes": response.total_bytes,
569
+ "average_duration_ms": response.average_duration_ms,
570
+ }
571
+
572
+ def network_export_har(
573
+ self,
574
+ session_id: str,
575
+ url_pattern: str = "",
576
+ methods: list[str] | None = None,
577
+ status_codes: list[int] | None = None,
578
+ resource_types: list[str] | None = None,
579
+ ) -> dict:
580
+ """Export captured exchanges to HAR format."""
581
+ from cmdop._generated.rpc_messages.browser_pb2 import BrowserNetworkExportHARRequest
582
+
583
+ request = BrowserNetworkExportHARRequest(
584
+ browser_session_id=session_id,
585
+ url_pattern=url_pattern,
586
+ methods=methods or [],
587
+ status_codes=status_codes or [],
588
+ resource_types=resource_types or [],
589
+ )
590
+ response = self._call_sync(self._get_stub.BrowserNetworkExportHAR, request)
591
+
592
+ if not response.success:
593
+ raise RuntimeError(f"NetworkExportHAR failed: {response.error}")
594
+
595
+ return {"har_data": response.har_data}
596
+
597
+ def _pb_to_exchange(self, pb: Any) -> dict:
598
+ """Convert protobuf exchange to dict."""
599
+ result: dict[str, Any] = {
600
+ "id": pb.id,
601
+ "error": pb.error,
602
+ "frame_id": pb.frame_id,
603
+ "initiator": pb.initiator,
604
+ }
605
+
606
+ if pb.request:
607
+ result["request"] = {
608
+ "url": pb.request.url,
609
+ "method": pb.request.method,
610
+ "headers": dict(pb.request.headers),
611
+ "body": pb.request.body,
612
+ "content_type": pb.request.content_type,
613
+ "resource_type": pb.request.resource_type,
614
+ }
615
+
616
+ if pb.response:
617
+ result["response"] = {
618
+ "status": pb.response.status,
619
+ "status_text": pb.response.status_text,
620
+ "headers": dict(pb.response.headers),
621
+ "body": pb.response.body,
622
+ "content_type": pb.response.content_type,
623
+ "size": pb.response.size,
624
+ "from_cache": pb.response.from_cache,
625
+ }
626
+
627
+ if pb.timing:
628
+ result["timing"] = {
629
+ "started_at_ms": pb.timing.started_at_ms,
630
+ "ended_at_ms": pb.timing.ended_at_ms,
631
+ "duration_ms": pb.timing.duration_ms,
632
+ "wait_time_ms": pb.timing.wait_time_ms,
633
+ "receive_time_ms": pb.timing.receive_time_ms,
634
+ }
635
+
636
+ return result
@@ -0,0 +1,194 @@
1
+ """Browser session with capability-based API."""
2
+
3
+ from __future__ import annotations
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from cmdop.services.browser.models import BrowserCookie, BrowserState, PageInfo, WaitUntil
7
+
8
+ from .capabilities import (
9
+ ScrollCapability,
10
+ InputCapability,
11
+ TimingCapability,
12
+ DOMCapability,
13
+ FetchCapability,
14
+ NetworkCapability,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from cmdop.services.browser.service.sync import BrowserService
19
+
20
+
21
+ class BrowserSession:
22
+ """Browser session with grouped capabilities.
23
+
24
+ Core methods (on session directly):
25
+ session.navigate(url)
26
+ session.click(selector)
27
+ session.type(selector, text)
28
+ session.wait_for(selector)
29
+ session.execute_script(js)
30
+
31
+ Capabilities (grouped by function):
32
+ session.scroll.js("down", 500)
33
+ session.scroll.to_bottom()
34
+ session.input.click_js(selector)
35
+ session.input.key("Escape")
36
+ session.timing.wait(1000)
37
+ session.dom.soup()
38
+ session.fetch.json("/api/data")
39
+
40
+ Usage:
41
+ with service.create_session() as session:
42
+ session.navigate("https://example.com")
43
+ session.scroll.js("down", 500)
44
+ session.input.click_js(".button")
45
+ """
46
+
47
+ __slots__ = (
48
+ "_service",
49
+ "_session_id",
50
+ "_scroll",
51
+ "_input",
52
+ "_timing",
53
+ "_dom",
54
+ "_fetch",
55
+ "_network",
56
+ )
57
+
58
+ def __init__(self, service: "BrowserService", session_id: str) -> None:
59
+ self._service = service
60
+ self._session_id = session_id
61
+ self._scroll: ScrollCapability | None = None
62
+ self._input: InputCapability | None = None
63
+ self._timing: TimingCapability | None = None
64
+ self._dom: DOMCapability | None = None
65
+ self._fetch: FetchCapability | None = None
66
+ self._network: NetworkCapability | None = None
67
+
68
+ @property
69
+ def session_id(self) -> str:
70
+ return self._session_id
71
+
72
+ # === Capabilities (lazy init) ===
73
+
74
+ @property
75
+ def scroll(self) -> ScrollCapability:
76
+ """Scroll: js(), to_bottom(), to_element(), info(), native(), collect()"""
77
+ if self._scroll is None:
78
+ self._scroll = ScrollCapability(self)
79
+ return self._scroll
80
+
81
+ @property
82
+ def input(self) -> InputCapability:
83
+ """Input: click_js(), key(), click_all(), hover(), hover_js()"""
84
+ if self._input is None:
85
+ self._input = InputCapability(self)
86
+ return self._input
87
+
88
+ @property
89
+ def timing(self) -> TimingCapability:
90
+ """Timing: wait(), seconds(), random(), timeout()"""
91
+ if self._timing is None:
92
+ self._timing = TimingCapability(self)
93
+ return self._timing
94
+
95
+ @property
96
+ def dom(self) -> DOMCapability:
97
+ """DOM: html(), text(), soup(), parse(), select(), close_modal(), extract()"""
98
+ if self._dom is None:
99
+ self._dom = DOMCapability(self)
100
+ return self._dom
101
+
102
+ @property
103
+ def fetch(self) -> FetchCapability:
104
+ """Fetch: json(), all(), execute()"""
105
+ if self._fetch is None:
106
+ self._fetch = FetchCapability(self)
107
+ return self._fetch
108
+
109
+ @property
110
+ def network(self) -> NetworkCapability:
111
+ """Network: enable(), disable(), get_all(), filter(), last(), clear(), stats()"""
112
+ if self._network is None:
113
+ self._network = NetworkCapability(self)
114
+ return self._network
115
+
116
+ # === Core Methods ===
117
+
118
+ def navigate(
119
+ self,
120
+ url: str,
121
+ timeout_ms: int = 30000,
122
+ wait_until: WaitUntil = WaitUntil.LOAD,
123
+ ) -> str:
124
+ """Navigate to URL with specified wait strategy.
125
+
126
+ Args:
127
+ url: URL to navigate to.
128
+ timeout_ms: Timeout in milliseconds.
129
+ wait_until: When navigation is considered complete:
130
+ - WaitUntil.LOAD: Wait for load event (default, slow for SPA)
131
+ - WaitUntil.DOMCONTENTLOADED: Wait for DOMContentLoaded event
132
+ - WaitUntil.NETWORKIDLE: Wait until network is idle (best for SPA)
133
+ - WaitUntil.COMMIT: Return immediately (fastest)
134
+
135
+ Returns:
136
+ Final URL after navigation (may differ due to redirects).
137
+ """
138
+ return self._service.navigate(self._session_id, url, timeout_ms, wait_until)
139
+
140
+ def click(self, selector: str, timeout_ms: int = 5000, move_cursor: bool = False) -> None:
141
+ """Click element by CSS selector."""
142
+ self._service.click(self._session_id, selector, timeout_ms, move_cursor)
143
+
144
+ def type(self, selector: str, text: str, human_like: bool = False, clear_first: bool = True) -> None:
145
+ """Type text into element."""
146
+ self._service.type(self._session_id, selector, text, human_like, clear_first)
147
+
148
+ def wait_for(self, selector: str, timeout_ms: int = 30000) -> bool:
149
+ """Wait for element to appear."""
150
+ return self._service.wait_for(self._session_id, selector, timeout_ms)
151
+
152
+ def execute_script(self, script: str) -> str:
153
+ """Execute raw JavaScript."""
154
+ return self._service.execute_script(self._session_id, script)
155
+
156
+ # === State ===
157
+
158
+ def screenshot(self, full_page: bool = False) -> bytes:
159
+ """Take screenshot."""
160
+ return self._service.screenshot(self._session_id, full_page)
161
+
162
+ def get_state(self) -> BrowserState:
163
+ """Get browser state."""
164
+ return self._service.get_state(self._session_id)
165
+
166
+ def get_cookies(self, domain: str = "") -> list[BrowserCookie]:
167
+ """Get cookies."""
168
+ return self._service.get_cookies(self._session_id, domain)
169
+
170
+ def set_cookies(self, cookies: list[BrowserCookie | dict]) -> None:
171
+ """Set cookies."""
172
+ self._service.set_cookies(self._session_id, cookies)
173
+
174
+ def get_page_info(self) -> PageInfo:
175
+ """Get page info."""
176
+ return self._service.get_page_info(self._session_id)
177
+
178
+ # === Internal ===
179
+
180
+ def _call_service(self, method: str, *args: Any, **kwargs: Any) -> Any:
181
+ """Call service method (used by capabilities)."""
182
+ return getattr(self._service, method)(self._session_id, *args, **kwargs)
183
+
184
+ # === Context Manager ===
185
+
186
+ def close(self) -> None:
187
+ """Close session."""
188
+ self._service.close_session(self._session_id)
189
+
190
+ def __enter__(self) -> "BrowserSession":
191
+ return self
192
+
193
+ def __exit__(self, *args: Any) -> None:
194
+ self.close()