browserrig 0.1.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.
Files changed (167) hide show
  1. browserrig/__init__.py +158 -0
  2. browserrig/activity/__init__.py +5 -0
  3. browserrig/activity/models.py +93 -0
  4. browserrig/activity/plugin.py +247 -0
  5. browserrig/activity/state.py +16 -0
  6. browserrig/agent.py +55 -0
  7. browserrig/browser/__init__.py +33 -0
  8. browserrig/browser/base.py +70 -0
  9. browserrig/browser/chrome.py +242 -0
  10. browserrig/browser/config.py +119 -0
  11. browserrig/browser/downloads.py +128 -0
  12. browserrig/browser/favicon.py +119 -0
  13. browserrig/browser/frame.py +227 -0
  14. browserrig/browser/frames.py +116 -0
  15. browserrig/browser/images.py +23 -0
  16. browserrig/browser/input.py +147 -0
  17. browserrig/browser/models.py +8 -0
  18. browserrig/browser/plugin.py +44 -0
  19. browserrig/browser/process.py +144 -0
  20. browserrig/browser/setup.py +12 -0
  21. browserrig/browser/tab.py +311 -0
  22. browserrig/browser/tabs.py +366 -0
  23. browserrig/browser/worlds.py +64 -0
  24. browserrig/choreography/__init__.py +31 -0
  25. browserrig/choreography/events.py +133 -0
  26. browserrig/choreography/plugin.py +75 -0
  27. browserrig/choreography/scenes.py +167 -0
  28. browserrig/choreography/stage.py +53 -0
  29. browserrig/choreography/tabstrip.py +112 -0
  30. browserrig/completion.py +184 -0
  31. browserrig/composition.py +108 -0
  32. browserrig/core/__init__.py +61 -0
  33. browserrig/core/errors.py +18 -0
  34. browserrig/core/geometry.py +149 -0
  35. browserrig/core/ids.py +10 -0
  36. browserrig/core/kinds.py +180 -0
  37. browserrig/core/options.py +7 -0
  38. browserrig/core/tools.py +32 -0
  39. browserrig/events.py +665 -0
  40. browserrig/files/__init__.py +25 -0
  41. browserrig/files/config.py +13 -0
  42. browserrig/files/containers.py +200 -0
  43. browserrig/files/content.py +65 -0
  44. browserrig/files/data.py +80 -0
  45. browserrig/files/documents.py +248 -0
  46. browserrig/files/formats.py +84 -0
  47. browserrig/files/imaging.py +138 -0
  48. browserrig/files/kinds.py +78 -0
  49. browserrig/files/models.py +71 -0
  50. browserrig/files/reading.py +382 -0
  51. browserrig/files/spreadsheets.py +417 -0
  52. browserrig/files/system.py +204 -0
  53. browserrig/files/text.py +64 -0
  54. browserrig/files/xlsx.py +53 -0
  55. browserrig/handoff/__init__.py +4 -0
  56. browserrig/handoff/changes.py +48 -0
  57. browserrig/handoff/models.py +31 -0
  58. browserrig/handoff/plugin.py +308 -0
  59. browserrig/handoff/state.py +21 -0
  60. browserrig/input/__init__.py +35 -0
  61. browserrig/input/controller.py +69 -0
  62. browserrig/input/keyboard.py +267 -0
  63. browserrig/input/motor.py +118 -0
  64. browserrig/input/mouse.py +346 -0
  65. browserrig/input/pointer.py +84 -0
  66. browserrig/input/presenter.py +49 -0
  67. browserrig/input/target.py +67 -0
  68. browserrig/input/wheel.py +40 -0
  69. browserrig/kinds.py +33 -0
  70. browserrig/nudges/__init__.py +3 -0
  71. browserrig/nudges/plugin.py +295 -0
  72. browserrig/nudges/state.py +23 -0
  73. browserrig/observation/__init__.py +53 -0
  74. browserrig/observation/capture.py +374 -0
  75. browserrig/observation/changes.py +63 -0
  76. browserrig/observation/config.py +12 -0
  77. browserrig/observation/content.py +176 -0
  78. browserrig/observation/delta.py +69 -0
  79. browserrig/observation/dom.py +97 -0
  80. browserrig/observation/extraction/__init__.py +335 -0
  81. browserrig/observation/extraction/accessibility.py +62 -0
  82. browserrig/observation/extraction/document.py +471 -0
  83. browserrig/observation/extraction/elements.py +676 -0
  84. browserrig/observation/extraction/semantics.py +351 -0
  85. browserrig/observation/extraction/styles.py +30 -0
  86. browserrig/observation/extraction/visibility.py +108 -0
  87. browserrig/observation/find.py +152 -0
  88. browserrig/observation/handle.py +170 -0
  89. browserrig/observation/images.py +243 -0
  90. browserrig/observation/marks.py +157 -0
  91. browserrig/observation/models.py +230 -0
  92. browserrig/observation/plugin.py +413 -0
  93. browserrig/observation/redirects.py +40 -0
  94. browserrig/observation/references.py +47 -0
  95. browserrig/observation/render.py +291 -0
  96. browserrig/observation/renderer.py +76 -0
  97. browserrig/observation/state.py +37 -0
  98. browserrig/pacing/__init__.py +5 -0
  99. browserrig/pacing/plugin.py +22 -0
  100. browserrig/py.typed +0 -0
  101. browserrig/readiness/__init__.py +25 -0
  102. browserrig/readiness/config.py +12 -0
  103. browserrig/readiness/dom.py +83 -0
  104. browserrig/readiness/downloads.py +30 -0
  105. browserrig/readiness/hooks.py +27 -0
  106. browserrig/readiness/models.py +13 -0
  107. browserrig/readiness/plugin.py +170 -0
  108. browserrig/readiness/policies.py +43 -0
  109. browserrig/readiness/state.py +20 -0
  110. browserrig/readiness/strategy.py +30 -0
  111. browserrig/readiness/wait.py +238 -0
  112. browserrig/scripts/README.md +39 -0
  113. browserrig/scripts/__init__.py +186 -0
  114. browserrig/scripts/content.js +526 -0
  115. browserrig/scripts/cursor.js +57 -0
  116. browserrig/scripts/element.js +82 -0
  117. browserrig/scripts/find.js +377 -0
  118. browserrig/scripts/focused.js +15 -0
  119. browserrig/scripts/lib/composed.js +21 -0
  120. browserrig/scripts/lib/elements.js +9 -0
  121. browserrig/scripts/lib/geometry.js +13 -0
  122. browserrig/scripts/lib/hit.js +84 -0
  123. browserrig/scripts/lib/markdown.js +32 -0
  124. browserrig/scripts/lib/scroll.js +226 -0
  125. browserrig/scripts/lib/secret.js +74 -0
  126. browserrig/scripts/lib/visibility.js +42 -0
  127. browserrig/scripts/models.py +153 -0
  128. browserrig/scripts/owner_hits.js +4 -0
  129. browserrig/scripts/owner_scroll.js +7 -0
  130. browserrig/scripts/owner_size.js +11 -0
  131. browserrig/scripts/page_facts.js +9 -0
  132. browserrig/scripts/reset.js +4 -0
  133. browserrig/scripts/scroll.js +5 -0
  134. browserrig/scripts/secret_field.js +7 -0
  135. browserrig/scripts/secret_focus.js +31 -0
  136. browserrig/scripts/seed.js +13 -0
  137. browserrig/scripts/stability.js +140 -0
  138. browserrig/scripts/tab_strip.js +27 -0
  139. browserrig/scripts/viewport.js +3 -0
  140. browserrig/secrets/__init__.py +5 -0
  141. browserrig/secrets/models.py +90 -0
  142. browserrig/secrets/origin.py +95 -0
  143. browserrig/secrets/plugin.py +397 -0
  144. browserrig/secrets/redaction.py +107 -0
  145. browserrig/secrets/state.py +6 -0
  146. browserrig/secrets/totp.py +62 -0
  147. browserrig/terminal.py +120 -0
  148. browserrig/tools/__init__.py +51 -0
  149. browserrig/tools/chains.py +55 -0
  150. browserrig/tools/dialogs.py +23 -0
  151. browserrig/tools/effects.py +8 -0
  152. browserrig/tools/execution.py +333 -0
  153. browserrig/tools/files.py +411 -0
  154. browserrig/tools/find.py +125 -0
  155. browserrig/tools/forms.py +76 -0
  156. browserrig/tools/intents.py +82 -0
  157. browserrig/tools/javascript.py +37 -0
  158. browserrig/tools/keyboard.py +126 -0
  159. browserrig/tools/mouse.py +224 -0
  160. browserrig/tools/navigation.py +189 -0
  161. browserrig/tools/scrolling.py +39 -0
  162. browserrig/tools/tabs.py +81 -0
  163. browserrig/tools/targeting.py +59 -0
  164. browserrig-0.1.0.dist-info/METADATA +174 -0
  165. browserrig-0.1.0.dist-info/RECORD +167 -0
  166. browserrig-0.1.0.dist-info/WHEEL +4 -0
  167. browserrig-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncGenerator, Sequence
4
+ from contextlib import asynccontextmanager, suppress
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal, overload
7
+
8
+ from cdpify import CdpSession
9
+ from cdpify.domains.page import FrameTree
10
+ from cdpify.domains.runtime import CallArgument, RemoteObject
11
+ from cdpify.exceptions import CDPCommandError
12
+ from pydantic import BaseModel
13
+
14
+ from browserrig.core import Bounds, BrowserError, FrameTransform
15
+ from browserrig.scripts import OWNER_HITS, OwnerHitsArgs, Script, failure
16
+
17
+ from .worlds import Worlds
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Frame:
22
+ """A CDP page frame: the top-level document or one embedded iframe.
23
+
24
+ Its isolated JavaScript world belongs to that frame, not to browser focus.
25
+ `transform` maps frame-local CSS pixels to the tab's viewport; `visible` is the
26
+ part of the frame left unclipped by its ancestors, in viewport pixels.
27
+ """
28
+
29
+ id: str
30
+ cdp: CdpSession
31
+ transform: FrameTransform = FrameTransform()
32
+ visible: Bounds | None = None
33
+ parent: Frame | None = None
34
+ owner_id: int | None = None
35
+ document_id: str = ""
36
+ worlds: Worlds = field(default_factory=Worlds, repr=False, compare=False)
37
+
38
+ @property
39
+ def local_visible(self) -> Bounds | None:
40
+ """The visible area in this frame's own CSS pixels, for in-page scripts."""
41
+ return (
42
+ self.transform.bounds_to_local(self.visible)
43
+ if self.visible is not None
44
+ else None
45
+ )
46
+
47
+ async def frame_tree(self) -> FrameTree:
48
+ result = await self.cdp.page.get_frame_tree()
49
+ return result.frame_tree
50
+
51
+ async def upload_files(self, backend_node_id: int, paths: list[str]) -> None:
52
+ try:
53
+ await self.cdp.dom.set_file_input_files(
54
+ files=paths, backend_node_id=backend_node_id
55
+ )
56
+ except CDPCommandError as error:
57
+ raise BrowserError("STALE_ELEMENT") from error
58
+
59
+ @overload
60
+ async def run_on_node[A: BaseModel, R](
61
+ self,
62
+ backend_node_id: int,
63
+ script: Script[A, R],
64
+ args: A | None = None,
65
+ *,
66
+ by_value: Literal[True] = True,
67
+ user_gesture: bool = False,
68
+ ) -> R: ...
69
+
70
+ @overload
71
+ async def run_on_node[A: BaseModel, R](
72
+ self,
73
+ backend_node_id: int,
74
+ script: Script[A, R],
75
+ args: A | None = None,
76
+ *,
77
+ by_value: Literal[False],
78
+ user_gesture: bool = False,
79
+ ) -> RemoteObject: ...
80
+
81
+ async def run_on_node[A: BaseModel, R](
82
+ self,
83
+ backend_node_id: int,
84
+ script: Script[A, R],
85
+ args: A | None = None,
86
+ *,
87
+ by_value: bool = True,
88
+ user_gesture: bool = False,
89
+ ) -> R | RemoteObject:
90
+ """Run with one of this frame's DOM nodes as `this`."""
91
+ return await self.run(
92
+ script,
93
+ args,
94
+ node=backend_node_id,
95
+ by_value=by_value,
96
+ user_gesture=user_gesture,
97
+ )
98
+
99
+ async def run_on_owner[A: BaseModel, R](
100
+ self, script: Script[A, R], args: A | None = None
101
+ ) -> R:
102
+ """Run with this frame's embedding element as `this`, using the same error policy."""
103
+ assert self.parent is not None and self.owner_id is not None
104
+ try:
105
+ return await self.parent.run_on_node(self.owner_id, script, args)
106
+ except BrowserError as error:
107
+ if str(error) == "STALE_ELEMENT":
108
+ raise BrowserError(
109
+ "Element frame no longer exists; use refs from the next observation"
110
+ ) from error
111
+ raise
112
+
113
+ async def check_point(self, x: float, y: float) -> None:
114
+ """A child hit test cannot see blockers in ancestor documents."""
115
+ if not (await self.visible_points(((x, y),)))[0]:
116
+ raise BrowserError("ELEMENT_BLOCKED")
117
+
118
+ async def visible_points(
119
+ self, points: tuple[tuple[float, float], ...]
120
+ ) -> tuple[bool, ...]:
121
+ visible = [True] * len(points)
122
+ current = self
123
+ while current.parent is not None:
124
+ visible_area = current.visible
125
+ hits = await current.run_on_owner(
126
+ OWNER_HITS,
127
+ OwnerHitsArgs(
128
+ points=tuple(
129
+ current.parent.transform.to_local(x, y) for x, y in points
130
+ )
131
+ ),
132
+ )
133
+ visible = [
134
+ before
135
+ and hit
136
+ and (visible_area is None or visible_area.contains(*point))
137
+ for before, hit, point in zip(visible, hits, points, strict=True)
138
+ ]
139
+ current = current.parent
140
+ return tuple(visible)
141
+
142
+ async def context(self) -> int:
143
+ return await self.worlds.context(self.cdp, self.id, self.document_id)
144
+
145
+ @overload
146
+ async def run[A: BaseModel, R](
147
+ self,
148
+ script: Script[A, R],
149
+ args: A | None = None,
150
+ *,
151
+ node: int | None = None,
152
+ objects: Sequence[str] = (),
153
+ by_value: Literal[True] = True,
154
+ user_gesture: bool = False,
155
+ ) -> R: ...
156
+
157
+ @overload
158
+ async def run[A: BaseModel, R](
159
+ self,
160
+ script: Script[A, R],
161
+ args: A | None = None,
162
+ *,
163
+ node: int | None = None,
164
+ objects: Sequence[str] = (),
165
+ by_value: Literal[False],
166
+ user_gesture: bool = False,
167
+ ) -> RemoteObject: ...
168
+
169
+ async def run[A: BaseModel, R](
170
+ self,
171
+ script: Script[A, R],
172
+ args: A | None = None,
173
+ *,
174
+ node: int | None = None,
175
+ objects: Sequence[str] = (),
176
+ by_value: bool = True,
177
+ user_gesture: bool = False,
178
+ ) -> R | RemoteObject:
179
+ async def call(context: int) -> RemoteObject:
180
+ async with _receiver(self.cdp, context, node) as object_id:
181
+ payload = script.arguments.model_validate({} if args is None else args)
182
+ result = await self.cdp.runtime.call_function_on(
183
+ function_declaration=script.declaration,
184
+ execution_context_id=context if object_id is None else None,
185
+ object_id=object_id,
186
+ arguments=[
187
+ CallArgument(
188
+ value=payload.model_dump(mode="json", by_alias=True)
189
+ ),
190
+ *(CallArgument(object_id=handle) for handle in objects),
191
+ ],
192
+ return_by_value=by_value,
193
+ await_promise=True,
194
+ user_gesture=user_gesture,
195
+ )
196
+ if result.exception_details:
197
+ raise BrowserError(failure(script, result.exception_details))
198
+ return result.result
199
+
200
+ result = await self.worlds.run(
201
+ self.cdp, self.id, self.document_id, call, retry=not objects
202
+ )
203
+ return script.parse(result.value) if by_value else result
204
+
205
+
206
+ @asynccontextmanager
207
+ async def _receiver(
208
+ cdp: CdpSession, context: int, backend_node_id: int | None
209
+ ) -> AsyncGenerator[str | None, None]:
210
+ """Resolve `this` in the isolated world and release it on every exit path."""
211
+ if backend_node_id is None:
212
+ yield None
213
+ return
214
+ try:
215
+ resolved = await cdp.dom.resolve_node(
216
+ backend_node_id=backend_node_id, execution_context_id=context
217
+ )
218
+ except CDPCommandError as error:
219
+ raise BrowserError("STALE_ELEMENT") from error
220
+ object_id = resolved.object.object_id
221
+ if object_id is None:
222
+ raise BrowserError("STALE_ELEMENT")
223
+ try:
224
+ yield object_id
225
+ finally:
226
+ with suppress(CDPCommandError):
227
+ await cdp.runtime.release_object(object_id=object_id)
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable, Callable
3
+ from dataclasses import replace
4
+
5
+ from cdpify import CdpSession
6
+ from cdpify.domains.page import FrameTree
7
+ from cdpify.domains.target import TargetInfo
8
+ from cdpify.exceptions import CDPCommandError
9
+
10
+ from browserrig.core import Bounds, BrowserError, FrameTransform
11
+ from browserrig.scripts import OWNER_SIZE, VIEWPORT
12
+
13
+ from .frame import Frame
14
+ from .tab import Tab
15
+
16
+ _MAX_FRAMES = 64
17
+ _MAX_DEPTH = 8
18
+ _FRAME_TIMEOUT = 2.0
19
+
20
+ type _FrameSession = Callable[[str], Awaitable[CdpSession]]
21
+
22
+
23
+ async def discover_frames(
24
+ tab: Tab, targets: tuple[TargetInfo, ...], session_for: _FrameSession
25
+ ) -> tuple[tuple[Frame, ...], tuple[str, ...]]:
26
+ """Return the tab's frames and why any were skipped.
27
+
28
+ Keep usable siblings when a frame detaches; bound work on frame-heavy pages.
29
+ """
30
+ root = await tab.frame_tree()
31
+ width, height = await (await tab.main_frame()).run(VIEWPORT)
32
+ viewport = Bounds(x=0, y=0, width=width, height=height)
33
+ result = [
34
+ Frame(
35
+ root.frame.id,
36
+ tab.cdp,
37
+ visible=viewport,
38
+ document_id=root.frame.loader_id,
39
+ worlds=tab.worlds,
40
+ )
41
+ ]
42
+ limitations: list[str] = []
43
+ target_map = {info.target_id: info for info in targets if info.type == "iframe"}
44
+ seen = {root.frame.id}
45
+
46
+ async def visit(
47
+ tree: FrameTree, parent: Frame, session_space: FrameTransform, depth: int
48
+ ):
49
+ children: dict[str, FrameTree | None] = {
50
+ child.frame.id: child for child in tree.child_frames or ()
51
+ }
52
+ # Some OOPIFs appear only in target discovery, not the embedding session's tree.
53
+ children.update(
54
+ (info.target_id, children.get(info.target_id))
55
+ for info in targets
56
+ if info.type == "iframe" and info.parent_frame_id == parent.id
57
+ )
58
+ for frame_id, child_tree in children.items():
59
+ if frame_id in seen:
60
+ continue
61
+ seen.add(frame_id)
62
+ if len(result) >= _MAX_FRAMES or depth >= _MAX_DEPTH:
63
+ limitations.append("Frame count or nesting limit reached")
64
+ return
65
+ try:
66
+ async with asyncio.timeout(_FRAME_TIMEOUT):
67
+ child = await inspect(frame_id, parent, session_space)
68
+ if child is None:
69
+ continue
70
+ if frame_id in target_map:
71
+ child_tree = await child.frame_tree()
72
+ if child_tree is not None:
73
+ child = replace(child, document_id=child_tree.frame.loader_id)
74
+ result.append(child)
75
+ if child_tree is not None:
76
+ child_space = (
77
+ child.transform if frame_id in target_map else session_space
78
+ )
79
+ await visit(child_tree, child, child_space, depth + 1)
80
+ except (CDPCommandError, BrowserError, TimeoutError, ValueError):
81
+ limitations.append(
82
+ f"Frame {frame_id} changed or could not be inspected"
83
+ )
84
+
85
+ async def inspect(
86
+ frame_id: str, parent: Frame, space: FrameTransform
87
+ ) -> Frame | None:
88
+ owner = await parent.cdp.dom.get_frame_owner(frame_id=frame_id)
89
+ child = Frame(
90
+ frame_id,
91
+ parent.cdp,
92
+ parent=parent,
93
+ owner_id=owner.backend_node_id,
94
+ worlds=tab.worlds,
95
+ )
96
+ size = await child.run_on_owner(OWNER_SIZE)
97
+ if not size or min(size) <= 0:
98
+ return None
99
+ box = await parent.cdp.dom.get_box_model(backend_node_id=owner.backend_node_id)
100
+ transform = space.child(box.model.content, *size)
101
+ visible = transform.bounds_to_viewport(
102
+ Bounds(x=0, y=0, width=size[0], height=size[1])
103
+ )
104
+ visible = visible.intersect(parent.visible or viewport)
105
+ # BoxModel quads are relative to the CDP session's root, not the parent frame.
106
+ cdp = await session_for(frame_id) if frame_id in target_map else parent.cdp
107
+ return replace(child, cdp=cdp, transform=transform, visible=visible)
108
+
109
+ try:
110
+ async with asyncio.timeout(10):
111
+ await visit(root, result[0], FrameTransform(), 0)
112
+ except TimeoutError:
113
+ limitations.append(
114
+ "Frame discovery timed out; some frames could not be inspected"
115
+ )
116
+ return tuple(result), tuple(dict.fromkeys(limitations))
@@ -0,0 +1,23 @@
1
+ from cdpify.domains.page import Viewport
2
+
3
+ from browserrig.core import Geometry
4
+
5
+ from .tab import Tab
6
+
7
+
8
+ async def screenshot(tab: Tab, geometry: Geometry | None = None) -> str:
9
+ clip = (
10
+ None
11
+ if geometry is None
12
+ else Viewport(
13
+ x=geometry.x,
14
+ y=geometry.y,
15
+ width=geometry.width,
16
+ height=geometry.height,
17
+ scale=geometry.scale,
18
+ )
19
+ )
20
+ result = await tab.cdp.page.capture_screenshot(
21
+ format="png", clip=clip, capture_beyond_viewport=False
22
+ )
23
+ return result.data
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Coroutine
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ from cdpify.domains.input import MouseButton
8
+
9
+ from browserrig.core import CursorStyle, MouseEventKind, Point
10
+ from browserrig.events import InputEvent
11
+ from browserrig.input import KeyEventType
12
+ from browserrig.scripts import CURSOR, CursorArgs
13
+
14
+ if TYPE_CHECKING:
15
+ from .tab import Tab
16
+
17
+ # A cursor shape is presentation only and must never hold up input.
18
+ _CURSOR_PROBE_SECONDS = 0.25
19
+
20
+
21
+ class CdpInput:
22
+ """Native input transport for the browser's devices, bound to one tab session.
23
+
24
+ This adapter owns CDP dispatch and dialog handling; Mouse and Keyboard own
25
+ gestures, device state and pacing.
26
+ """
27
+
28
+ def __init__(self, tab: Tab) -> None:
29
+ self._tab = tab
30
+ self.dialog_opened = asyncio.Event()
31
+
32
+ @property
33
+ def id(self) -> str:
34
+ return self._tab.id
35
+
36
+ async def viewport_size(self) -> tuple[float, float]:
37
+ return await self._tab.viewport_size()
38
+
39
+ def layout_changed(self) -> None:
40
+ self._tab.layout_changed()
41
+
42
+ async def emit(self, event: InputEvent) -> None:
43
+ await self._tab.emit(event)
44
+
45
+ async def cursor_style(self, point: Point) -> CursorStyle | None:
46
+ """Return the CSS cursor under a viewport point, or None when the page cannot say."""
47
+ if self._tab.dialog is not None:
48
+ return None
49
+ try:
50
+ async with asyncio.timeout(_CURSOR_PROBE_SECONDS):
51
+ frame = await self._tab.main_frame()
52
+ style = await frame.run(CURSOR, CursorArgs(x=point.x, y=point.y))
53
+ except Exception: # pylint: disable=broad-exception-caught
54
+ # Navigation, dialogs and detached sessions must not fail the input itself.
55
+ return None
56
+ return CursorStyle.parse(style)
57
+
58
+ async def dispatch_mouse(
59
+ self,
60
+ kind: Literal[
61
+ MouseEventKind.MOVED, MouseEventKind.PRESSED, MouseEventKind.RELEASED
62
+ ],
63
+ point: Point,
64
+ *,
65
+ button: MouseButton,
66
+ buttons: int,
67
+ click_count: int,
68
+ ) -> None:
69
+ await self._input(
70
+ self._tab.cdp.input.dispatch_mouse_event(
71
+ type=kind.value,
72
+ x=point.x,
73
+ y=point.y,
74
+ button=button,
75
+ buttons=buttons,
76
+ click_count=click_count,
77
+ )
78
+ )
79
+
80
+ async def dispatch_wheel(
81
+ self, point: Point, *, delta_x: float, delta_y: float
82
+ ) -> None:
83
+ await self._input(
84
+ self._tab.cdp.input.dispatch_mouse_event(
85
+ type=MouseEventKind.WHEEL.value, # smelt: ignore[SMT318] -- cdpify wants a Literal
86
+ x=point.x,
87
+ y=point.y,
88
+ delta_x=delta_x,
89
+ delta_y=delta_y,
90
+ )
91
+ )
92
+
93
+ async def dispatch_key(
94
+ self,
95
+ kind: KeyEventType,
96
+ *,
97
+ modifiers: int,
98
+ key: str,
99
+ code: str,
100
+ windows_virtual_key_code: int,
101
+ text: str | None = None,
102
+ ) -> None:
103
+ await self._input(
104
+ self._tab.cdp.input.dispatch_key_event(
105
+ type=kind,
106
+ modifiers=modifiers,
107
+ key=key,
108
+ code=code,
109
+ windows_virtual_key_code=windows_virtual_key_code,
110
+ text=text,
111
+ )
112
+ )
113
+
114
+ async def cancel_drag(self) -> None:
115
+ await self._tab.cdp.input.cancel_dragging()
116
+
117
+ async def _input(self, command: Coroutine[Any, Any, None]) -> None:
118
+ """Send input, returning early when it opens a JavaScript dialog.
119
+
120
+ Chrome answers an input event only after its handlers finish, and an alert, confirm
121
+ or prompt holds them until the dialog closes. The input has landed by then, so the
122
+ reply is left to arrive later.
123
+ """
124
+ if self._tab.dialog is not None:
125
+ await command
126
+ return
127
+ # Started eagerly, input that needs no reply lands within the caller's step.
128
+ sent = asyncio.Task(command, loop=asyncio.get_running_loop(), eager_start=True)
129
+ if not sent.done():
130
+ opened = asyncio.ensure_future(self.dialog_opened.wait())
131
+ try:
132
+ await asyncio.wait((sent, opened), return_when=asyncio.FIRST_COMPLETED)
133
+ except asyncio.CancelledError:
134
+ sent.cancel()
135
+ raise
136
+ finally:
137
+ opened.cancel()
138
+ if not sent.done():
139
+ sent.add_done_callback(_discard_result)
140
+ return
141
+ sent.result()
142
+
143
+
144
+ def _discard_result(task: asyncio.Future[None]) -> None:
145
+ # The reply of input that opened a dialog only reports when the dialog closed.
146
+ if not task.cancelled():
147
+ task.exception()
@@ -0,0 +1,8 @@
1
+ from browserrig.core import Options
2
+
3
+
4
+ class TabInfo(Options):
5
+ id: str
6
+ url: str
7
+ title: str
8
+ active: bool = False
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass
2
+
3
+ from harnessed import Context, Inject, Plugin, SessionLog
4
+ from harnessed.kernel import Event
5
+
6
+ from browserrig.events import BROWSER_EVENTS
7
+
8
+ from .base import Browser, BrowserEvents
9
+ from .chrome import ChromeBrowser
10
+ from .config import BrowserOptions, Launch
11
+ from .tab import Tab
12
+
13
+
14
+ class Chrome(Plugin, slot="browserrig.browser", needs=(SessionLog,)):
15
+ events = BROWSER_EVENTS
16
+
17
+ def __init__(self, options: BrowserOptions | None = None) -> None:
18
+ super().__init__()
19
+ self.options = options if options is not None else Launch()
20
+
21
+ async def setup(self, ctx: Context, log: Inject[SessionLog]) -> None:
22
+ async def tab_changed(tab: Tab, settled: bool) -> None:
23
+ await ctx.dispatch(TabChanged(tab, settled))
24
+
25
+ browser = await ctx.enter(
26
+ ChromeBrowser(
27
+ self.options,
28
+ emit=log.emit,
29
+ on_tab_change=tab_changed,
30
+ )
31
+ )
32
+ ctx.provide(Browser, browser)
33
+ ctx.provide(BrowserEvents, browser.events)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TabChanged(Event[None]):
38
+ """`tab` may show a new url, title or loading state, or has closed.
39
+
40
+ `settled` marks a page that stopped loading, so its document can be read.
41
+ """
42
+
43
+ tab: Tab
44
+ settled: bool