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,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)
|