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,144 @@
1
+ import asyncio
2
+ import errno
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from asyncio.subprocess import Process
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from typing import Self
10
+
11
+ from browserrig.core import BrowserError
12
+
13
+
14
+ class ChromeProcess:
15
+ """Chrome on a throwaway profile; `flags` are added to the ones it owns."""
16
+
17
+ def __init__(
18
+ self, *, executable: Path | None, flags: tuple[str, ...], timeout: float
19
+ ) -> None:
20
+ self._executable = executable
21
+ self._flags = flags
22
+ self._timeout = timeout
23
+ self._process: Process | None = None
24
+ self._profile: TemporaryDirectory[str] | None = None
25
+ self.endpoint = ""
26
+
27
+ async def __aenter__(self) -> Self:
28
+ binary = _chrome_executable(self._executable)
29
+ # The profile outlives this method; __aexit__ removes it with the process.
30
+ self._profile = TemporaryDirectory( # pylint: disable=consider-using-with
31
+ prefix="browserrig-chrome-"
32
+ )
33
+ flags = [
34
+ binary,
35
+ "--remote-debugging-address=127.0.0.1",
36
+ "--remote-debugging-port=0",
37
+ f"--user-data-dir={self._profile.name}",
38
+ "--no-first-run",
39
+ "--no-default-browser-check",
40
+ *self._flags,
41
+ "about:blank",
42
+ ]
43
+ try:
44
+ self._process = await asyncio.create_subprocess_exec(
45
+ *flags,
46
+ stdin=subprocess.DEVNULL,
47
+ stdout=subprocess.DEVNULL,
48
+ stderr=subprocess.DEVNULL,
49
+ creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
50
+ )
51
+ active_port = Path(self._profile.name) / "DevToolsActivePort"
52
+ async with asyncio.timeout(self._timeout):
53
+ while True:
54
+ if self._process.returncode is not None:
55
+ raise BrowserError(
56
+ f"Chrome exited during startup ({self._process.returncode})"
57
+ )
58
+ endpoint = await asyncio.to_thread(_active_endpoint, active_port)
59
+ if endpoint is not None:
60
+ self.endpoint = endpoint
61
+ return self
62
+ await asyncio.sleep(0.05)
63
+ except (Exception, asyncio.CancelledError):
64
+ await self.aclose()
65
+ raise
66
+
67
+ async def __aexit__(self, *_: object) -> None:
68
+ await self.aclose()
69
+
70
+ async def aclose(self) -> None:
71
+ if self._process is not None and self._process.returncode is None:
72
+ self._process.terminate()
73
+ try:
74
+ async with asyncio.timeout(5):
75
+ await self._process.wait()
76
+ except TimeoutError:
77
+ self._process.kill()
78
+ await self._process.wait()
79
+ self._process = None
80
+ if self._profile is not None:
81
+ profile = self._profile
82
+ deadline = asyncio.get_running_loop().time() + 10
83
+ while True:
84
+ try:
85
+ await asyncio.to_thread(profile.cleanup)
86
+ self._profile = None
87
+ break
88
+ except OSError as error:
89
+ # Chrome helpers can retain handles or repopulate profile
90
+ # directories briefly after the browser parent exits.
91
+ if (
92
+ not isinstance(error, PermissionError)
93
+ and error.errno != errno.ENOTEMPTY
94
+ ):
95
+ raise
96
+ if asyncio.get_running_loop().time() >= deadline:
97
+ # A leftover temp profile is not worth failing the session for.
98
+ await asyncio.to_thread(
99
+ shutil.rmtree, profile.name, ignore_errors=True
100
+ )
101
+ self._profile = None
102
+ break
103
+ await asyncio.sleep(0.1)
104
+
105
+
106
+ def _chrome_executable(explicit: Path | None = None) -> str:
107
+ candidates = [explicit, os.environ.get("BROWSERRIG_CHROME")]
108
+ candidates.extend(
109
+ shutil.which(name) for name in ("chromium", "chromium-browser", "google-chrome")
110
+ )
111
+ for root in (
112
+ os.environ.get("PROGRAMFILES"),
113
+ os.environ.get("PROGRAMFILES(X86)"),
114
+ os.environ.get("LOCALAPPDATA"),
115
+ ):
116
+ if root:
117
+ candidates.extend(
118
+ Path(root) / part
119
+ for part in (
120
+ "Google/Chrome/Application/chrome.exe",
121
+ "Microsoft/Edge/Application/msedge.exe",
122
+ )
123
+ )
124
+ for candidate in candidates:
125
+ if candidate and Path(candidate).is_file():
126
+ return str(Path(candidate).resolve())
127
+ raise BrowserError(
128
+ "Chrome was not found; set Launch(executable=...) or BROWSERRIG_CHROME"
129
+ )
130
+
131
+
132
+ def _active_endpoint(path: Path) -> str | None:
133
+ try:
134
+ lines = path.read_text(encoding="utf-8").splitlines()
135
+ except (FileNotFoundError, PermissionError):
136
+ # Chrome can still hold the port file while publishing its endpoint on Windows.
137
+ return None
138
+ if (
139
+ len(lines) >= 2
140
+ and lines[0].isdigit()
141
+ and lines[1].startswith("/devtools/browser/")
142
+ ):
143
+ return f"ws://127.0.0.1:{lines[0]}{lines[1]}"
144
+ return None
@@ -0,0 +1,12 @@
1
+ from typing import Protocol, runtime_checkable
2
+
3
+ from cdpify import CdpSession
4
+
5
+
6
+ @runtime_checkable
7
+ class SessionSetup(Protocol):
8
+ """Configure each newly attached page or out-of-process frame CDP session."""
9
+
10
+ async def apply( # pylint: disable=unused-argument
11
+ self, cdp: CdpSession, *, frame: bool
12
+ ) -> None: ...
@@ -0,0 +1,311 @@
1
+ import asyncio
2
+ import json
3
+ from collections.abc import Awaitable, Callable
4
+ from contextlib import suppress
5
+ from dataclasses import replace
6
+ from urllib.parse import urlsplit
7
+ from uuid import uuid4
8
+
9
+ from cdpify import CdpSession
10
+ from cdpify.domains.page import (
11
+ FrameNavigatedEvent,
12
+ FrameStartedLoadingEvent,
13
+ FrameStartedNavigatingEvent,
14
+ FrameStoppedLoadingEvent,
15
+ FrameTree,
16
+ JavascriptDialogClosedEvent,
17
+ JavascriptDialogOpeningEvent,
18
+ LifecycleEventEvent,
19
+ NavigatedWithinDocumentEvent,
20
+ )
21
+ from cdpify.domains.runtime import RemoteObject
22
+ from cdpify.exceptions import CDPCommandError
23
+
24
+ from browserrig.core import BrowserError
25
+ from browserrig.events import (
26
+ BrowserEvent,
27
+ DialogHandledEvent,
28
+ DialogOpenedEvent,
29
+ LiveEvent,
30
+ NavigationCommittedEvent,
31
+ )
32
+ from browserrig.scripts import (
33
+ TAB_STRIP,
34
+ TabStrip,
35
+ )
36
+
37
+ from .favicon import load_favicon
38
+ from .frame import Frame
39
+ from .input import CdpInput
40
+ from .worlds import Worlds
41
+
42
+ type FrameDiscovery = Callable[
43
+ [Tab], Awaitable[tuple[tuple[Frame, ...], tuple[str, ...]]]
44
+ ]
45
+ # Told when url, title or loading may have changed; `settled` once loading stopped.
46
+ type TabListener = Callable[[Tab, bool], Awaitable[None]]
47
+
48
+
49
+ class Tab: # pylint: disable=too-many-public-methods # document API
50
+ def __init__(
51
+ self,
52
+ target_id: str,
53
+ cdp: CdpSession,
54
+ *,
55
+ emit: Callable[[BrowserEvent | LiveEvent], Awaitable[None]] | None = None,
56
+ worlds: Worlds | None = None,
57
+ discover_frames: FrameDiscovery | None = None,
58
+ current_action: Callable[[], str] = lambda: "",
59
+ on_change: TabListener | None = None,
60
+ ) -> None:
61
+ self.id = target_id
62
+ self.cdp = cdp
63
+ self.document_id = ""
64
+ self.url = "about:blank"
65
+ self.title = ""
66
+ self.dialog: tuple[str, str] | None = None
67
+ self.input = CdpInput(self)
68
+ self.closed = False
69
+ self.loading = False
70
+ self.navigation_pending = False
71
+ self._navigation_loader = ""
72
+ self._navigation_document = ""
73
+ self._main_frame_id = target_id
74
+ self.changed = asyncio.Event()
75
+ self.frame_limitations: tuple[str, ...] = ()
76
+ self.worlds = worlds if worlds is not None else Worlds()
77
+ self._emit = emit
78
+ self._discover_frames = discover_frames
79
+ self._layout_revision = 0
80
+ self._frames_key: tuple[str, str, int] | None = None
81
+ self._frames: tuple[Frame, ...] = ()
82
+ self._current_action = current_action
83
+ self._on_change = on_change
84
+
85
+ async def handle(self, event: object) -> None:
86
+ """Apply a page event that arrived on this tab's own CDP session."""
87
+ match event:
88
+ case FrameNavigatedEvent(frame=frame) if not frame.parent_id:
89
+ self.document_id = frame.loader_id
90
+ self.url = frame.url
91
+ self._main_frame_id = frame.id
92
+ self.changed.set()
93
+ await self.emit(NavigationCommittedEvent(url=self.url))
94
+ await self.report_change()
95
+ case NavigatedWithinDocumentEvent(url=url, frame_id=frame_id) if (
96
+ frame_id == self._main_frame_id
97
+ ):
98
+ self.navigation_pending = False
99
+ self.url = url
100
+ self.changed.set()
101
+ await self.emit(NavigationCommittedEvent(url=self.url))
102
+ await self.report_change()
103
+ case FrameStartedNavigatingEvent(frame_id=frame_id, loader_id=loader) if (
104
+ frame_id == self._main_frame_id
105
+ ):
106
+ self.navigation_pending = True
107
+ self._navigation_loader = loader
108
+ self._navigation_document = self.document_id
109
+ case FrameStartedLoadingEvent(frame_id=frame_id) if (
110
+ frame_id == self._main_frame_id
111
+ ):
112
+ self.loading = True
113
+ if not self.navigation_pending:
114
+ self._navigation_loader = ""
115
+ self._navigation_document = self.document_id
116
+ self.navigation_pending = True
117
+ await self.report_change()
118
+ case LifecycleEventEvent(
119
+ frame_id=frame_id, loader_id=loader, name="DOMContentLoaded"
120
+ ) if frame_id == self._main_frame_id:
121
+ if loader == self._navigation_loader or (
122
+ loader == self.document_id
123
+ and self.document_id != self._navigation_document
124
+ ):
125
+ self.navigation_pending = False
126
+ case FrameStoppedLoadingEvent(frame_id=frame_id) if (
127
+ frame_id == self._main_frame_id
128
+ ):
129
+ self.loading = False
130
+ self.navigation_pending = False
131
+ await self.report_change(settled=True)
132
+ case JavascriptDialogOpeningEvent(type=kind, message=message):
133
+ self.dialog = (str(kind), message)
134
+ self.input.dialog_opened.set()
135
+ await self.emit(
136
+ DialogOpenedEvent(kind=str(kind), message=message[:1000])
137
+ )
138
+ case JavascriptDialogClosedEvent(result=accepted):
139
+ self.dialog = None
140
+ self.input.dialog_opened.clear()
141
+ await self.emit(DialogHandledEvent(accepted=accepted))
142
+
143
+ async def emit(self, event: BrowserEvent | LiveEvent) -> None:
144
+ if self._emit is not None:
145
+ await self._emit(
146
+ replace(event, target_id=self.id, document_id=self.document_id)
147
+ )
148
+
149
+ async def discard(self) -> None:
150
+ self.closed = True
151
+ await self.report_change()
152
+
153
+ async def report_change(self, *, settled: bool = False) -> None:
154
+ """Tell the change listener that url, title or loading may have changed."""
155
+ if self._on_change is not None:
156
+ await self._on_change(self, settled)
157
+
158
+ async def read_strip(self) -> TabStrip:
159
+ """Read the title and icon URL the document declares for a tab strip."""
160
+ frame = await self.main_frame()
161
+ return await frame.run(TAB_STRIP)
162
+
163
+ async def load_icon(self, url: str) -> str:
164
+ """Load an icon through the page's own network stack, as a `data:` URI."""
165
+ return await load_favicon(self.cdp, self._main_frame_id, url)
166
+
167
+ async def sync_document(self) -> None:
168
+ if self.dialog is not None:
169
+ # The page answers nothing while a dialog blocks it, and cannot navigate either.
170
+ return
171
+ tree = await self.frame_tree()
172
+ if self.document_id != tree.frame.loader_id:
173
+ self.worlds.invalidate(frame_id=tree.frame.id)
174
+ self.layout_changed()
175
+ self.document_id = tree.frame.loader_id
176
+ self.url = tree.frame.url
177
+ self._main_frame_id = tree.frame.id
178
+
179
+ async def evaluate(self, expression: str, *, timeout: float = 10) -> str:
180
+ """Evaluate once in the main page world and render its completion value."""
181
+ group = f"browserrig-evaluate-{uuid4().hex}"
182
+ try:
183
+ async with asyncio.timeout(timeout):
184
+ result = await self.cdp.runtime.evaluate(
185
+ expression=expression,
186
+ object_group=group,
187
+ return_by_value=True,
188
+ await_promise=True,
189
+ repl_mode=True,
190
+ user_gesture=True,
191
+ timeout=timeout * 1000,
192
+ )
193
+ if details := result.exception_details:
194
+ message = (
195
+ _javascript_value(details.exception)
196
+ if details.exception is not None
197
+ else details.text
198
+ )
199
+ raise BrowserError(f"JavaScript failed: {message}")
200
+ return _javascript_value(result.result)
201
+ except TimeoutError as error:
202
+ raise BrowserError(
203
+ f"JavaScript timed out after {timeout:g} seconds"
204
+ ) from error
205
+ except CDPCommandError as error:
206
+ if error.message == "Object couldn't be returned by value":
207
+ raise BrowserError(
208
+ "JavaScript executed, but its result cannot be returned by value; "
209
+ "return JSON-serializable data"
210
+ ) from error
211
+ raise
212
+ finally:
213
+ self.layout_changed()
214
+ with suppress(CDPCommandError):
215
+ await self.cdp.runtime.release_object_group(object_group=group)
216
+
217
+ def layout_changed(self) -> None:
218
+ """Forget cached frame geometry after input, scrolling or navigation moved content."""
219
+ self._layout_revision += 1
220
+
221
+ async def viewport_size(self) -> tuple[float, float]:
222
+ viewport = (await self.cdp.page.get_layout_metrics()).css_layout_viewport
223
+ return viewport.client_width, viewport.client_height
224
+
225
+ async def frames(self) -> tuple[Frame, ...]:
226
+ """Return every frame with its viewport transform; `frame_limitations` explains gaps.
227
+
228
+ One discovery is reused within an action until input or navigation invalidates it.
229
+ """
230
+ if self._discover_frames is None:
231
+ raise BrowserError("Frame discovery is unavailable for this tab")
232
+ key = (self.document_id, self._current_action(), self._layout_revision)
233
+ if key[1] and key == self._frames_key:
234
+ return self._frames
235
+ frames, self.frame_limitations = await self._discover_frames(self)
236
+ if key[1] and key == (
237
+ self.document_id,
238
+ self._current_action(),
239
+ self._layout_revision,
240
+ ):
241
+ self._frames_key = key
242
+ self._frames = frames
243
+ return frames
244
+
245
+ async def main_frame(self) -> Frame:
246
+ tree = await self.frame_tree()
247
+ return Frame(
248
+ tree.frame.id,
249
+ self.cdp,
250
+ document_id=tree.frame.loader_id,
251
+ worlds=self.worlds,
252
+ )
253
+
254
+ async def frame_tree(self) -> FrameTree:
255
+ result = await self.cdp.page.get_frame_tree()
256
+ return result.frame_tree
257
+
258
+ async def go_back(self) -> bool:
259
+ return await self._history(-1)
260
+
261
+ async def go_forward(self) -> bool:
262
+ return await self._history(1)
263
+
264
+ async def can_go_forward(self) -> bool:
265
+ history = await self.cdp.page.get_navigation_history()
266
+ return history.current_index + 1 < len(history.entries)
267
+
268
+ async def reload(self) -> None:
269
+ await self.cdp.page.reload()
270
+
271
+ async def handle_dialog(self, *, accept: bool, text: str = "") -> None:
272
+ await self.cdp.page.handle_java_script_dialog(accept=accept, prompt_text=text)
273
+
274
+ async def bring_to_front(self) -> None:
275
+ await self.cdp.page.bring_to_front()
276
+
277
+ async def navigate(self, url: str) -> None:
278
+ result = await self.cdp.page.navigate(url=validated_url(url))
279
+ if result.error_text:
280
+ raise BrowserError(f"Navigation failed: {result.error_text}")
281
+
282
+ async def _history(self, offset: int) -> bool:
283
+ history = await self.cdp.page.get_navigation_history()
284
+ index = history.current_index + offset
285
+ if not 0 <= index < len(history.entries):
286
+ return False
287
+ await self.cdp.page.navigate_to_history_entry(
288
+ entry_id=history.entries[index].id
289
+ )
290
+ return True
291
+
292
+
293
+ def validated_url(url: str) -> str:
294
+ parsed = urlsplit(url)
295
+ if url == "about:blank" or (parsed.scheme in {"http", "https"} and parsed.hostname):
296
+ return url
297
+ raise BrowserError("Navigation requires an HTTP(S) URL or about:blank")
298
+
299
+
300
+ def _javascript_value(value: RemoteObject) -> str:
301
+ if value.unserializable_value is not None:
302
+ return value.unserializable_value
303
+ if value.type == "undefined":
304
+ return "undefined"
305
+ if (
306
+ value.value is None
307
+ and value.subtype != "null"
308
+ and value.description is not None
309
+ ):
310
+ return value.description
311
+ return json.dumps(value.value, ensure_ascii=False)