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.
- browserrig/__init__.py +158 -0
- browserrig/activity/__init__.py +5 -0
- browserrig/activity/models.py +93 -0
- browserrig/activity/plugin.py +247 -0
- browserrig/activity/state.py +16 -0
- browserrig/agent.py +55 -0
- browserrig/browser/__init__.py +33 -0
- browserrig/browser/base.py +70 -0
- browserrig/browser/chrome.py +242 -0
- browserrig/browser/config.py +119 -0
- browserrig/browser/downloads.py +128 -0
- browserrig/browser/favicon.py +119 -0
- browserrig/browser/frame.py +227 -0
- browserrig/browser/frames.py +116 -0
- browserrig/browser/images.py +23 -0
- browserrig/browser/input.py +147 -0
- browserrig/browser/models.py +8 -0
- browserrig/browser/plugin.py +44 -0
- browserrig/browser/process.py +144 -0
- browserrig/browser/setup.py +12 -0
- browserrig/browser/tab.py +311 -0
- browserrig/browser/tabs.py +366 -0
- browserrig/browser/worlds.py +64 -0
- browserrig/choreography/__init__.py +31 -0
- browserrig/choreography/events.py +133 -0
- browserrig/choreography/plugin.py +75 -0
- browserrig/choreography/scenes.py +167 -0
- browserrig/choreography/stage.py +53 -0
- browserrig/choreography/tabstrip.py +112 -0
- browserrig/completion.py +184 -0
- browserrig/composition.py +108 -0
- browserrig/core/__init__.py +61 -0
- browserrig/core/errors.py +18 -0
- browserrig/core/geometry.py +149 -0
- browserrig/core/ids.py +10 -0
- browserrig/core/kinds.py +180 -0
- browserrig/core/options.py +7 -0
- browserrig/core/tools.py +32 -0
- browserrig/events.py +665 -0
- browserrig/files/__init__.py +25 -0
- browserrig/files/config.py +13 -0
- browserrig/files/containers.py +200 -0
- browserrig/files/content.py +65 -0
- browserrig/files/data.py +80 -0
- browserrig/files/documents.py +248 -0
- browserrig/files/formats.py +84 -0
- browserrig/files/imaging.py +138 -0
- browserrig/files/kinds.py +78 -0
- browserrig/files/models.py +71 -0
- browserrig/files/reading.py +382 -0
- browserrig/files/spreadsheets.py +417 -0
- browserrig/files/system.py +204 -0
- browserrig/files/text.py +64 -0
- browserrig/files/xlsx.py +53 -0
- browserrig/handoff/__init__.py +4 -0
- browserrig/handoff/changes.py +48 -0
- browserrig/handoff/models.py +31 -0
- browserrig/handoff/plugin.py +308 -0
- browserrig/handoff/state.py +21 -0
- browserrig/input/__init__.py +35 -0
- browserrig/input/controller.py +69 -0
- browserrig/input/keyboard.py +267 -0
- browserrig/input/motor.py +118 -0
- browserrig/input/mouse.py +346 -0
- browserrig/input/pointer.py +84 -0
- browserrig/input/presenter.py +49 -0
- browserrig/input/target.py +67 -0
- browserrig/input/wheel.py +40 -0
- browserrig/kinds.py +33 -0
- browserrig/nudges/__init__.py +3 -0
- browserrig/nudges/plugin.py +295 -0
- browserrig/nudges/state.py +23 -0
- browserrig/observation/__init__.py +53 -0
- browserrig/observation/capture.py +374 -0
- browserrig/observation/changes.py +63 -0
- browserrig/observation/config.py +12 -0
- browserrig/observation/content.py +176 -0
- browserrig/observation/delta.py +69 -0
- browserrig/observation/dom.py +97 -0
- browserrig/observation/extraction/__init__.py +335 -0
- browserrig/observation/extraction/accessibility.py +62 -0
- browserrig/observation/extraction/document.py +471 -0
- browserrig/observation/extraction/elements.py +676 -0
- browserrig/observation/extraction/semantics.py +351 -0
- browserrig/observation/extraction/styles.py +30 -0
- browserrig/observation/extraction/visibility.py +108 -0
- browserrig/observation/find.py +152 -0
- browserrig/observation/handle.py +170 -0
- browserrig/observation/images.py +243 -0
- browserrig/observation/marks.py +157 -0
- browserrig/observation/models.py +230 -0
- browserrig/observation/plugin.py +413 -0
- browserrig/observation/redirects.py +40 -0
- browserrig/observation/references.py +47 -0
- browserrig/observation/render.py +291 -0
- browserrig/observation/renderer.py +76 -0
- browserrig/observation/state.py +37 -0
- browserrig/pacing/__init__.py +5 -0
- browserrig/pacing/plugin.py +22 -0
- browserrig/py.typed +0 -0
- browserrig/readiness/__init__.py +25 -0
- browserrig/readiness/config.py +12 -0
- browserrig/readiness/dom.py +83 -0
- browserrig/readiness/downloads.py +30 -0
- browserrig/readiness/hooks.py +27 -0
- browserrig/readiness/models.py +13 -0
- browserrig/readiness/plugin.py +170 -0
- browserrig/readiness/policies.py +43 -0
- browserrig/readiness/state.py +20 -0
- browserrig/readiness/strategy.py +30 -0
- browserrig/readiness/wait.py +238 -0
- browserrig/scripts/README.md +39 -0
- browserrig/scripts/__init__.py +186 -0
- browserrig/scripts/content.js +526 -0
- browserrig/scripts/cursor.js +57 -0
- browserrig/scripts/element.js +82 -0
- browserrig/scripts/find.js +377 -0
- browserrig/scripts/focused.js +15 -0
- browserrig/scripts/lib/composed.js +21 -0
- browserrig/scripts/lib/elements.js +9 -0
- browserrig/scripts/lib/geometry.js +13 -0
- browserrig/scripts/lib/hit.js +84 -0
- browserrig/scripts/lib/markdown.js +32 -0
- browserrig/scripts/lib/scroll.js +226 -0
- browserrig/scripts/lib/secret.js +74 -0
- browserrig/scripts/lib/visibility.js +42 -0
- browserrig/scripts/models.py +153 -0
- browserrig/scripts/owner_hits.js +4 -0
- browserrig/scripts/owner_scroll.js +7 -0
- browserrig/scripts/owner_size.js +11 -0
- browserrig/scripts/page_facts.js +9 -0
- browserrig/scripts/reset.js +4 -0
- browserrig/scripts/scroll.js +5 -0
- browserrig/scripts/secret_field.js +7 -0
- browserrig/scripts/secret_focus.js +31 -0
- browserrig/scripts/seed.js +13 -0
- browserrig/scripts/stability.js +140 -0
- browserrig/scripts/tab_strip.js +27 -0
- browserrig/scripts/viewport.js +3 -0
- browserrig/secrets/__init__.py +5 -0
- browserrig/secrets/models.py +90 -0
- browserrig/secrets/origin.py +95 -0
- browserrig/secrets/plugin.py +397 -0
- browserrig/secrets/redaction.py +107 -0
- browserrig/secrets/state.py +6 -0
- browserrig/secrets/totp.py +62 -0
- browserrig/terminal.py +120 -0
- browserrig/tools/__init__.py +51 -0
- browserrig/tools/chains.py +55 -0
- browserrig/tools/dialogs.py +23 -0
- browserrig/tools/effects.py +8 -0
- browserrig/tools/execution.py +333 -0
- browserrig/tools/files.py +411 -0
- browserrig/tools/find.py +125 -0
- browserrig/tools/forms.py +76 -0
- browserrig/tools/intents.py +82 -0
- browserrig/tools/javascript.py +37 -0
- browserrig/tools/keyboard.py +126 -0
- browserrig/tools/mouse.py +224 -0
- browserrig/tools/navigation.py +189 -0
- browserrig/tools/scrolling.py +39 -0
- browserrig/tools/tabs.py +81 -0
- browserrig/tools/targeting.py +59 -0
- browserrig-0.1.0.dist-info/METADATA +174 -0
- browserrig-0.1.0.dist-info/RECORD +167 -0
- browserrig-0.1.0.dist-info/WHEEL +4 -0
- 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,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
|