browser-daemon-client 0.1.0__tar.gz

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.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: browser-daemon-client
3
+ Version: 0.1.0
4
+ Summary: Async client for browser-daemon: Playwright runs over HTTP, sessions and logins, with nothing but aiohttp
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/pschmitt/browser-daemon
7
+ Project-URL: Documentation, https://github.com/pschmitt/browser-daemon/blob/main/docs/run.md
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: aiohttp>=3.9
11
+
12
+ # browser-daemon-client
13
+
14
+ Async Python client for [browser-daemon](https://github.com/pschmitt/browser-daemon),
15
+ with nothing but `aiohttp`: made for Home Assistant integrations and other code
16
+ that cannot carry Playwright.
17
+
18
+ ```python
19
+ import aiohttp
20
+ from browser_daemon_client import BrowserDaemon, RunError, step
21
+
22
+ async with aiohttp.ClientSession() as http:
23
+ bd = BrowserDaemon(
24
+ http, "https://browser.example.org", secret, client="Home Assistant · HR WORKS"
25
+ )
26
+ try:
27
+ res = await bd.run(
28
+ profile="work",
29
+ url="https://hr.example.org/login",
30
+ steps=[
31
+ step.fill("#user", arg="user"),
32
+ step.fill("#password", arg="password"),
33
+ step.click("role=button[name='Sign in']"),
34
+ step.wait_for(".dashboard"),
35
+ step.text(".balance", as_="balance"),
36
+ ],
37
+ args={"user": user, "password": password},
38
+ )
39
+ except RunError as err:
40
+ print(err, err.run.url) # err.run.screenshot: the tab, as a base64 PNG
41
+ else:
42
+ print(res.result["balance"])
43
+ ```
44
+
45
+ - `run()`: a Playwright `script` (a JavaScript function expression) or `steps`,
46
+ in a profile's browser, a session or a throwaway browser. Runs for a profile
47
+ go to the node that has it. See [docs/run.md](../docs/run.md).
48
+ - `overview()`, `find_profile()`, `sessions()`, `session()`, `create_session()`,
49
+ `delete_session()`, `set_idle_timeout()`, `tabs()`, `open_tab()`,
50
+ `close_tab()`, `export_auth()` (a page's cookies and storage; never log them).
51
+ - `node=` sends a request to another node of the mesh through this one.
52
+ - `read_secret()` reads the cluster secret from its file.
53
+
54
+ Errors are `BrowserDaemonError` (with `status`), and for runs `RunError` and
55
+ `RunTimeout` (with the whole answer in `.run`).
56
+
57
+ ## Releasing
58
+
59
+ Bump `version` in `pyproject.toml`, commit, and push a signed tag
60
+ `python-vX.Y.Z`: the `python-client` workflow tests, builds and publishes it to
61
+ PyPI through trusted publishing (the `pypi` environment, tags `python-v*` only;
62
+ no token is stored anywhere).
@@ -0,0 +1,51 @@
1
+ # browser-daemon-client
2
+
3
+ Async Python client for [browser-daemon](https://github.com/pschmitt/browser-daemon),
4
+ with nothing but `aiohttp`: made for Home Assistant integrations and other code
5
+ that cannot carry Playwright.
6
+
7
+ ```python
8
+ import aiohttp
9
+ from browser_daemon_client import BrowserDaemon, RunError, step
10
+
11
+ async with aiohttp.ClientSession() as http:
12
+ bd = BrowserDaemon(
13
+ http, "https://browser.example.org", secret, client="Home Assistant · HR WORKS"
14
+ )
15
+ try:
16
+ res = await bd.run(
17
+ profile="work",
18
+ url="https://hr.example.org/login",
19
+ steps=[
20
+ step.fill("#user", arg="user"),
21
+ step.fill("#password", arg="password"),
22
+ step.click("role=button[name='Sign in']"),
23
+ step.wait_for(".dashboard"),
24
+ step.text(".balance", as_="balance"),
25
+ ],
26
+ args={"user": user, "password": password},
27
+ )
28
+ except RunError as err:
29
+ print(err, err.run.url) # err.run.screenshot: the tab, as a base64 PNG
30
+ else:
31
+ print(res.result["balance"])
32
+ ```
33
+
34
+ - `run()`: a Playwright `script` (a JavaScript function expression) or `steps`,
35
+ in a profile's browser, a session or a throwaway browser. Runs for a profile
36
+ go to the node that has it. See [docs/run.md](../docs/run.md).
37
+ - `overview()`, `find_profile()`, `sessions()`, `session()`, `create_session()`,
38
+ `delete_session()`, `set_idle_timeout()`, `tabs()`, `open_tab()`,
39
+ `close_tab()`, `export_auth()` (a page's cookies and storage; never log them).
40
+ - `node=` sends a request to another node of the mesh through this one.
41
+ - `read_secret()` reads the cluster secret from its file.
42
+
43
+ Errors are `BrowserDaemonError` (with `status`), and for runs `RunError` and
44
+ `RunTimeout` (with the whole answer in `.run`).
45
+
46
+ ## Releasing
47
+
48
+ Bump `version` in `pyproject.toml`, commit, and push a signed tag
49
+ `python-vX.Y.Z`: the `python-client` workflow tests, builds and publishes it to
50
+ PyPI through trusted publishing (the `pypi` environment, tags `python-v*` only;
51
+ no token is stored anywhere).
@@ -0,0 +1,398 @@
1
+ """Async client for browser-daemon's HTTP API, with nothing but aiohttp.
2
+
3
+ Made for Home Assistant integrations and other code that cannot carry
4
+ Playwright: :meth:`BrowserDaemon.run` runs a Playwright script or a list of
5
+ steps on the node (see docs/run.md), and the other methods cover sessions,
6
+ tabs and a page's login.
7
+
8
+ async with aiohttp.ClientSession() as http:
9
+ bd = BrowserDaemon(http, "https://browser.example.org", secret)
10
+ res = await bd.run(
11
+ profile="work",
12
+ url="https://example.org/login",
13
+ steps=[
14
+ step.fill("#user", arg="user"),
15
+ step.fill("#password", arg="password"),
16
+ step.click("role=button[name='Sign in']"),
17
+ step.text(".balance", as_="balance"),
18
+ ],
19
+ args={"user": user, "password": password},
20
+ )
21
+ print(res.result["balance"])
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+ from urllib.parse import quote
29
+
30
+ import aiohttp
31
+
32
+ __all__ = [
33
+ "BrowserDaemon",
34
+ "BrowserDaemonError",
35
+ "RunError",
36
+ "RunResult",
37
+ "RunTimeout",
38
+ "read_secret",
39
+ "step",
40
+ ]
41
+
42
+ SECRET_HEADER = "X-Browser-Cluster-Secret"
43
+ DEFAULT_SECRET_FILE = "/run/secrets/browser-daemon/cluster-secret"
44
+
45
+
46
+ class BrowserDaemonError(Exception):
47
+ """The daemon refused a request or could not be reached."""
48
+
49
+ def __init__(self, message: str, status: int | None = None) -> None:
50
+ super().__init__(message)
51
+ self.status = status
52
+
53
+
54
+ @dataclass
55
+ class RunResult:
56
+ """The answer of a run (see docs/run.md, "The answer")."""
57
+
58
+ ok: bool
59
+ result: Any = None
60
+ error: str | None = None
61
+ timed_out: bool = False
62
+ steps: list[dict[str, Any]] = field(default_factory=list)
63
+ logs: list[dict[str, Any]] = field(default_factory=list)
64
+ url: str | None = None
65
+ page: str | None = None
66
+ #: The tab when the run failed: a base64 PNG.
67
+ screenshot: str | None = None
68
+ ms: int | None = None
69
+
70
+ @classmethod
71
+ def from_json(cls, data: dict[str, Any]) -> RunResult:
72
+ return cls(
73
+ ok=bool(data.get("ok")),
74
+ result=data.get("result"),
75
+ error=data.get("error"),
76
+ timed_out=bool(data.get("timed_out")),
77
+ steps=data.get("steps") or [],
78
+ logs=data.get("logs") or [],
79
+ url=data.get("url"),
80
+ page=data.get("page"),
81
+ screenshot=data.get("screenshot"),
82
+ ms=data.get("ms"),
83
+ )
84
+
85
+
86
+ class RunError(BrowserDaemonError):
87
+ """The script or a step failed; :attr:`run` holds the whole answer."""
88
+
89
+ def __init__(self, run: RunResult, status: int | None = None) -> None:
90
+ super().__init__(run.error or "The run failed", status)
91
+ self.run = run
92
+
93
+
94
+ class RunTimeout(RunError):
95
+ """The run hit its timeout."""
96
+
97
+
98
+ def read_secret(path: str = DEFAULT_SECRET_FILE) -> str | None:
99
+ """The cluster secret from a file, or None when it cannot be read."""
100
+ try:
101
+ with open(path, encoding="utf-8") as f:
102
+ return f.read().strip() or None
103
+ except OSError:
104
+ return None
105
+
106
+
107
+ class BrowserDaemon:
108
+ """A node of the mesh. Requests for other nodes go through it (`node=`)."""
109
+
110
+ def __init__(
111
+ self,
112
+ session: aiohttp.ClientSession,
113
+ url: str,
114
+ secret: str | None = None,
115
+ *,
116
+ client: str | None = None,
117
+ timeout: float = 30,
118
+ ) -> None:
119
+ self.session = session
120
+ self.url = url.rstrip("/")
121
+ self.secret = secret
122
+ #: Shown on the dashboard for sessions and runs ("Home Assistant · HR WORKS").
123
+ self.client = client
124
+ self.timeout = timeout
125
+ self.local_node: str | None = None
126
+
127
+ async def _request(
128
+ self,
129
+ method: str,
130
+ path: str,
131
+ *,
132
+ node: str | None = None,
133
+ timeout: float | None = None,
134
+ accept: tuple[int, ...] = (),
135
+ **kwargs: Any,
136
+ ) -> tuple[int, Any]:
137
+ if node and node != self.local_node:
138
+ path = f"/api/peers/{quote(node, safe='')}/{path.removeprefix('/api/').lstrip('/')}"
139
+ headers = {SECRET_HEADER: self.secret} if self.secret else {}
140
+ try:
141
+ async with self.session.request(
142
+ method,
143
+ self.url + path,
144
+ headers=headers,
145
+ timeout=aiohttp.ClientTimeout(total=timeout or self.timeout),
146
+ **kwargs,
147
+ ) as res:
148
+ data = await res.json(content_type=None) if res.status != 204 else None
149
+ if res.status < 300 or res.status in accept:
150
+ return res.status, data
151
+ message = data.get("error") if isinstance(data, dict) else None
152
+ raise BrowserDaemonError(
153
+ message or f"browser-daemon returned HTTP {res.status}", res.status
154
+ )
155
+ except (aiohttp.ClientError, TimeoutError) as err:
156
+ raise BrowserDaemonError(f"browser-daemon is unreachable: {err!r}") from err
157
+ except ValueError as err:
158
+ raise BrowserDaemonError("browser-daemon sent an unreadable answer") from err
159
+
160
+ async def _json(self, method: str, path: str, **kwargs: Any) -> Any:
161
+ return (await self._request(method, path, **kwargs))[1]
162
+
163
+ # The mesh
164
+
165
+ async def overview(self) -> dict[str, Any]:
166
+ """Nodes, sessions, profiles and tabs of the whole mesh."""
167
+ data = await self._json("GET", "/api/mesh/overview")
168
+ self.local_node = (data.get("local_node") or {}).get("name")
169
+ return data
170
+
171
+ async def find_profile(self, name: str) -> dict[str, Any] | None:
172
+ """A profile of the mesh by name, with its `node_name` and `session_id`."""
173
+ overview = await self.overview()
174
+ return next((p for p in overview.get("profiles", []) if p.get("name") == name), None)
175
+
176
+ # Runs
177
+
178
+ async def run(
179
+ self,
180
+ *,
181
+ script: str | None = None,
182
+ steps: list[dict[str, Any]] | None = None,
183
+ args: dict[str, Any] | None = None,
184
+ profile: str | None = None,
185
+ session_id: str | None = None,
186
+ tab: str | None = None,
187
+ url: str | None = None,
188
+ keep_open: bool = False,
189
+ persistent: bool = False,
190
+ timeout: float = 60,
191
+ action_timeout: float | None = None,
192
+ screenshot_on_error: bool = True,
193
+ idle_timeout_minutes: int | None = None,
194
+ node: str | None = None,
195
+ check: bool = True,
196
+ ) -> RunResult:
197
+ """Run a script or steps in a browser of the mesh (see docs/run.md).
198
+
199
+ Raises :class:`RunError` (:class:`RunTimeout`) when the run fails,
200
+ unless `check` is false, and :class:`BrowserDaemonError` when it could
201
+ not start.
202
+ """
203
+ body: dict[str, Any] = {
204
+ "script": script,
205
+ "steps": steps,
206
+ "args": args,
207
+ "profile": profile,
208
+ "session_id": session_id,
209
+ "tab": tab,
210
+ "url": url,
211
+ "keep_open": keep_open,
212
+ "persistent": persistent,
213
+ "timeout_seconds": max(1, round(timeout)),
214
+ "action_timeout_seconds": round(action_timeout) if action_timeout else None,
215
+ "screenshot_on_error": screenshot_on_error,
216
+ "idle_timeout_minutes": idle_timeout_minutes,
217
+ "client": self.client,
218
+ }
219
+ status, data = await self._request(
220
+ "POST",
221
+ "/api/run",
222
+ node=node,
223
+ json={k: v for k, v in body.items() if v is not None},
224
+ timeout=timeout + 45,
225
+ accept=(422, 504),
226
+ )
227
+ res = RunResult.from_json(data or {})
228
+ if check and not res.ok:
229
+ raise (RunTimeout if res.timed_out else RunError)(res, status)
230
+ return res
231
+
232
+ # Sessions and tabs
233
+
234
+ async def sessions(self, node: str | None = None) -> list[dict[str, Any]]:
235
+ return await self._json("GET", "/api/sessions", node=node)
236
+
237
+ async def session(self, session_id: str, node: str | None = None) -> dict[str, Any]:
238
+ """The session and its `targets` (tabs)."""
239
+ return await self._json("GET", f"/api/sessions/{quote(session_id, safe='')}", node=node)
240
+
241
+ async def create_session(
242
+ self, *, profile: str | None = None, node: str | None = None, **options: Any
243
+ ) -> dict[str, Any]:
244
+ """Start a session (or attach to the profile's); options as `POST /api/sessions`."""
245
+ body = {"persistent": True, "profile": profile, **options}
246
+ if self.client and "name" not in body:
247
+ body["name"] = self.client
248
+ return await self._json(
249
+ "POST",
250
+ "/api/sessions",
251
+ node=node,
252
+ json={k: v for k, v in body.items() if v is not None},
253
+ )
254
+
255
+ async def delete_session(self, session_id: str, node: str | None = None) -> None:
256
+ await self._request("DELETE", f"/api/sessions/{quote(session_id, safe='')}", node=node)
257
+
258
+ async def set_idle_timeout(
259
+ self, session_id: str, minutes: int, node: str | None = None
260
+ ) -> None:
261
+ await self._request(
262
+ "POST",
263
+ f"/api/sessions/{quote(session_id, safe='')}/idle-timeout",
264
+ node=node,
265
+ params={"minutes": minutes},
266
+ )
267
+
268
+ async def tabs(self, node: str | None = None) -> list[dict[str, Any]]:
269
+ """The pages of all sessions of the node (`/json/list`)."""
270
+ return await self._json("GET", "/json/list", node=node)
271
+
272
+ async def open_tab(
273
+ self, url: str, *, session_id: str, node: str | None = None
274
+ ) -> dict[str, Any]:
275
+ return await self._json(
276
+ "PUT", "/json/new", node=node, params={"url": url, "session_id": session_id}
277
+ )
278
+
279
+ async def close_tab(self, page_id: str, node: str | None = None) -> None:
280
+ await self._request("PUT", f"/json/close/{quote(page_id, safe='')}", node=node)
281
+
282
+ async def export_auth(
283
+ self,
284
+ page_id: str,
285
+ *,
286
+ session_id: str | None = None,
287
+ urls: list[str] | None = None,
288
+ storage: bool = False,
289
+ node: str | None = None,
290
+ ) -> dict[str, Any]:
291
+ """A page's cookies (and local/session storage): its login, for an API client.
292
+
293
+ The answer is credentials: never log it.
294
+ """
295
+ body: dict[str, Any] = {"urls": urls or [], "storage": storage}
296
+ if session_id:
297
+ body["session_id"] = session_id
298
+ return await self._json(
299
+ "POST", f"/api/pages/{quote(page_id, safe='')}/auth", node=node, json=body
300
+ )
301
+
302
+
303
+ class step:
304
+ """Builders for run steps (docs/run.md, "Steps"). `as_` names the value."""
305
+
306
+ @staticmethod
307
+ def _make(action: str, as_: str | None = None, **fields: Any) -> dict[str, Any]:
308
+ out = {"action": action, **{k: v for k, v in fields.items() if v is not None}}
309
+ if as_:
310
+ out["as"] = as_
311
+ return out
312
+
313
+ @staticmethod
314
+ def goto(url: str, *, wait_until: str | None = None, **kw: Any) -> dict[str, Any]:
315
+ return step._make("goto", url=url, wait_until=wait_until, **kw)
316
+
317
+ @staticmethod
318
+ def click(selector: str, **kw: Any) -> dict[str, Any]:
319
+ return step._make("click", selector=selector, **kw)
320
+
321
+ @staticmethod
322
+ def fill(
323
+ selector: str, value: str | None = None, *, arg: str | None = None, **kw: Any
324
+ ) -> dict[str, Any]:
325
+ """Fill an input with `value`, or with `args[arg]` (keeps secrets out of the steps)."""
326
+ return step._make("fill", selector=selector, value=value, value_arg=arg, **kw)
327
+
328
+ @staticmethod
329
+ def type(
330
+ selector: str, value: str | None = None, *, arg: str | None = None, **kw: Any
331
+ ) -> dict[str, Any]:
332
+ return step._make("type", selector=selector, value=value, value_arg=arg, **kw)
333
+
334
+ @staticmethod
335
+ def press(key: str, selector: str | None = None, **kw: Any) -> dict[str, Any]:
336
+ return step._make("press", key=key, selector=selector, **kw)
337
+
338
+ @staticmethod
339
+ def select(
340
+ selector: str, value: Any = None, *, arg: str | None = None, **kw: Any
341
+ ) -> dict[str, Any]:
342
+ return step._make("select", selector=selector, value=value, value_arg=arg, **kw)
343
+
344
+ @staticmethod
345
+ def check(selector: str, checked: bool = True, **kw: Any) -> dict[str, Any]:
346
+ return step._make("check", selector=selector, checked=checked, **kw)
347
+
348
+ @staticmethod
349
+ def wait_for(
350
+ selector: str | None = None,
351
+ *,
352
+ state: str | None = None,
353
+ url: str | None = None,
354
+ load_state: str | None = None,
355
+ ms: int | None = None,
356
+ **kw: Any,
357
+ ) -> dict[str, Any]:
358
+ return step._make(
359
+ "wait_for", selector=selector, state=state, url=url, load_state=load_state, ms=ms, **kw
360
+ )
361
+
362
+ @staticmethod
363
+ def text(selector: str, *, all: bool | None = None, **kw: Any) -> dict[str, Any]:
364
+ return step._make("text", selector=selector, all=all, **kw)
365
+
366
+ @staticmethod
367
+ def value(selector: str, **kw: Any) -> dict[str, Any]:
368
+ return step._make("value", selector=selector, **kw)
369
+
370
+ @staticmethod
371
+ def attribute(selector: str, name: str, **kw: Any) -> dict[str, Any]:
372
+ return step._make("attribute", selector=selector, name=name, **kw)
373
+
374
+ @staticmethod
375
+ def count(selector: str, **kw: Any) -> dict[str, Any]:
376
+ return step._make("count", selector=selector, **kw)
377
+
378
+ @staticmethod
379
+ def visible(selector: str, **kw: Any) -> dict[str, Any]:
380
+ return step._make("visible", selector=selector, **kw)
381
+
382
+ @staticmethod
383
+ def evaluate(expression: str, arg: Any = None, **kw: Any) -> dict[str, Any]:
384
+ return step._make("evaluate", expression=expression, arg=arg, **kw)
385
+
386
+ @staticmethod
387
+ def screenshot(
388
+ selector: str | None = None, *, full_page: bool | None = None, **kw: Any
389
+ ) -> dict[str, Any]:
390
+ return step._make("screenshot", selector=selector, full_page=full_page, **kw)
391
+
392
+ @staticmethod
393
+ def url(**kw: Any) -> dict[str, Any]:
394
+ return step._make("url", **kw)
395
+
396
+ @staticmethod
397
+ def title(**kw: Any) -> dict[str, Any]:
398
+ return step._make("title", **kw)
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: browser-daemon-client
3
+ Version: 0.1.0
4
+ Summary: Async client for browser-daemon: Playwright runs over HTTP, sessions and logins, with nothing but aiohttp
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/pschmitt/browser-daemon
7
+ Project-URL: Documentation, https://github.com/pschmitt/browser-daemon/blob/main/docs/run.md
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: aiohttp>=3.9
11
+
12
+ # browser-daemon-client
13
+
14
+ Async Python client for [browser-daemon](https://github.com/pschmitt/browser-daemon),
15
+ with nothing but `aiohttp`: made for Home Assistant integrations and other code
16
+ that cannot carry Playwright.
17
+
18
+ ```python
19
+ import aiohttp
20
+ from browser_daemon_client import BrowserDaemon, RunError, step
21
+
22
+ async with aiohttp.ClientSession() as http:
23
+ bd = BrowserDaemon(
24
+ http, "https://browser.example.org", secret, client="Home Assistant · HR WORKS"
25
+ )
26
+ try:
27
+ res = await bd.run(
28
+ profile="work",
29
+ url="https://hr.example.org/login",
30
+ steps=[
31
+ step.fill("#user", arg="user"),
32
+ step.fill("#password", arg="password"),
33
+ step.click("role=button[name='Sign in']"),
34
+ step.wait_for(".dashboard"),
35
+ step.text(".balance", as_="balance"),
36
+ ],
37
+ args={"user": user, "password": password},
38
+ )
39
+ except RunError as err:
40
+ print(err, err.run.url) # err.run.screenshot: the tab, as a base64 PNG
41
+ else:
42
+ print(res.result["balance"])
43
+ ```
44
+
45
+ - `run()`: a Playwright `script` (a JavaScript function expression) or `steps`,
46
+ in a profile's browser, a session or a throwaway browser. Runs for a profile
47
+ go to the node that has it. See [docs/run.md](../docs/run.md).
48
+ - `overview()`, `find_profile()`, `sessions()`, `session()`, `create_session()`,
49
+ `delete_session()`, `set_idle_timeout()`, `tabs()`, `open_tab()`,
50
+ `close_tab()`, `export_auth()` (a page's cookies and storage; never log them).
51
+ - `node=` sends a request to another node of the mesh through this one.
52
+ - `read_secret()` reads the cluster secret from its file.
53
+
54
+ Errors are `BrowserDaemonError` (with `status`), and for runs `RunError` and
55
+ `RunTimeout` (with the whole answer in `.run`).
56
+
57
+ ## Releasing
58
+
59
+ Bump `version` in `pyproject.toml`, commit, and push a signed tag
60
+ `python-vX.Y.Z`: the `python-client` workflow tests, builds and publishes it to
61
+ PyPI through trusted publishing (the `pypi` environment, tags `python-v*` only;
62
+ no token is stored anywhere).
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ browser_daemon_client/__init__.py
4
+ browser_daemon_client.egg-info/PKG-INFO
5
+ browser_daemon_client.egg-info/SOURCES.txt
6
+ browser_daemon_client.egg-info/dependency_links.txt
7
+ browser_daemon_client.egg-info/requires.txt
8
+ browser_daemon_client.egg-info/top_level.txt
9
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ browser_daemon_client
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "browser-daemon-client"
7
+ version = "0.1.0"
8
+ description = "Async client for browser-daemon: Playwright runs over HTTP, sessions and logins, with nothing but aiohttp"
9
+ readme = "README.md"
10
+ license = "GPL-3.0-only"
11
+ requires-python = ">=3.11"
12
+ dependencies = ["aiohttp>=3.9"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/pschmitt/browser-daemon"
16
+ Documentation = "https://github.com/pschmitt/browser-daemon/blob/main/docs/run.md"
17
+
18
+ [tool.setuptools]
19
+ packages = ["browser_daemon_client"]
20
+
21
+ [tool.pytest.ini_options]
22
+ asyncio_mode = "auto"
23
+
24
+ [tool.ruff]
25
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,164 @@
1
+ """The client against a fake daemon: what it sends and how it reads the answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import aiohttp
8
+ import pytest
9
+ from aiohttp import web
10
+ from aiohttp.test_utils import TestServer
11
+
12
+ from browser_daemon_client import (
13
+ BrowserDaemon,
14
+ BrowserDaemonError,
15
+ RunError,
16
+ RunTimeout,
17
+ read_secret,
18
+ step,
19
+ )
20
+
21
+
22
+ @pytest.fixture
23
+ async def daemon():
24
+ """A fake node: records every request and answers from `answers`."""
25
+ seen: list[dict] = []
26
+ answers: dict[tuple[str, str], tuple[int, object]] = {}
27
+
28
+ async def handle(request: web.Request) -> web.Response:
29
+ body = await request.text()
30
+ seen.append(
31
+ {
32
+ "method": request.method,
33
+ "path": request.rel_url.raw_path,
34
+ "query": dict(request.query),
35
+ "secret": request.headers.get("X-Browser-Cluster-Secret"),
36
+ "json": json.loads(body) if body else None,
37
+ }
38
+ )
39
+ status, data = answers.get(
40
+ (request.method, request.rel_url.raw_path), (404, {"error": "nope"})
41
+ )
42
+ return web.json_response(data, status=status)
43
+
44
+ app = web.Application()
45
+ app.router.add_route("*", "/{tail:.*}", handle)
46
+ server = TestServer(app)
47
+ await server.start_server()
48
+ async with aiohttp.ClientSession() as http:
49
+ bd = BrowserDaemon(http, str(server.make_url("/")), "s3cret", client="tests")
50
+ yield bd, seen, answers
51
+ await server.close()
52
+
53
+
54
+ async def test_a_run_sends_only_what_is_set(daemon):
55
+ bd, seen, answers = daemon
56
+ answers[("POST", "/api/run")] = (200, {"ok": True, "result": {"t": "x"}, "ms": 5})
57
+ res = await bd.run(
58
+ profile="work",
59
+ steps=[step.fill("#pw", arg="password"), step.text("h1", as_="t")],
60
+ args={"password": "hunter2"},
61
+ timeout=30,
62
+ )
63
+ assert res.ok and res.result == {"t": "x"} and res.ms == 5
64
+ sent = seen[0]
65
+ assert sent["secret"] == "s3cret"
66
+ assert sent["json"] == {
67
+ "profile": "work",
68
+ "steps": [
69
+ {"action": "fill", "selector": "#pw", "value_arg": "password"},
70
+ {"action": "text", "selector": "h1", "as": "t"},
71
+ ],
72
+ "args": {"password": "hunter2"},
73
+ "keep_open": False,
74
+ "persistent": False,
75
+ "timeout_seconds": 30,
76
+ "screenshot_on_error": True,
77
+ "client": "tests",
78
+ }
79
+
80
+
81
+ async def test_a_failed_run_raises_with_its_answer(daemon):
82
+ bd, _, answers = daemon
83
+ answers[("POST", "/api/run")] = (
84
+ 422,
85
+ {
86
+ "ok": False,
87
+ "error": "step 0 (click): timeout",
88
+ "screenshot": "iVBOR",
89
+ "url": "https://x/",
90
+ },
91
+ )
92
+ with pytest.raises(RunError) as err:
93
+ await bd.run(script="async () => 1")
94
+ assert not isinstance(err.value, RunTimeout)
95
+ assert str(err.value) == "step 0 (click): timeout"
96
+ assert err.value.status == 422
97
+ assert err.value.run.screenshot == "iVBOR"
98
+ assert err.value.run.url == "https://x/"
99
+ res = await bd.run(script="async () => 1", check=False)
100
+ assert not res.ok
101
+
102
+
103
+ async def test_a_timed_out_run_raises_run_timeout(daemon):
104
+ bd, _, answers = daemon
105
+ answers[("POST", "/api/run")] = (504, {"ok": False, "timed_out": True, "error": "timed out"})
106
+ with pytest.raises(RunTimeout):
107
+ await bd.run(script="async () => 1")
108
+
109
+
110
+ async def test_a_refused_request_carries_the_daemons_message(daemon):
111
+ bd, _, answers = daemon
112
+ answers[("POST", "/api/run")] = (501, {"error": "Runs are not enabled on this node"})
113
+ with pytest.raises(BrowserDaemonError) as err:
114
+ await bd.run(script="async () => 1")
115
+ assert not isinstance(err.value, RunError)
116
+ assert err.value.status == 501
117
+ assert "not enabled" in str(err.value)
118
+
119
+
120
+ async def test_requests_for_other_nodes_go_through_the_peer_route(daemon):
121
+ bd, seen, answers = daemon
122
+ answers[("GET", "/api/mesh/overview")] = (
123
+ 200,
124
+ {"local_node": {"name": "here"}, "profiles": [{"name": "work", "node_name": "there"}]},
125
+ )
126
+ answers[("GET", "/api/peers/there/json/list")] = (200, [])
127
+ answers[("GET", "/api/sessions")] = (200, [])
128
+ answers[("POST", "/api/peers/there/sessions/a%2Fb/idle-timeout")] = (204, None)
129
+ assert (await bd.find_profile("work"))["node_name"] == "there"
130
+ assert await bd.tabs(node="there") == []
131
+ assert await bd.sessions(node="here") == []
132
+ await bd.set_idle_timeout("a/b", 5, node="there")
133
+ assert [s["path"] for s in seen[1:]] == [
134
+ "/api/peers/there/json/list",
135
+ "/api/sessions",
136
+ "/api/peers/there/sessions/a%2Fb/idle-timeout",
137
+ ]
138
+ assert seen[-1]["query"] == {"minutes": "5"}
139
+
140
+
141
+ async def test_a_session_is_named_after_the_client(daemon):
142
+ bd, seen, answers = daemon
143
+ answers[("POST", "/api/sessions")] = (201, {"id": "abc"})
144
+ assert (await bd.create_session(profile="work", default_tabs=False))["id"] == "abc"
145
+ assert seen[0]["json"] == {
146
+ "persistent": True,
147
+ "profile": "work",
148
+ "default_tabs": False,
149
+ "name": "tests",
150
+ }
151
+
152
+
153
+ async def test_an_unreachable_daemon_is_a_browser_daemon_error():
154
+ async with aiohttp.ClientSession() as http:
155
+ bd = BrowserDaemon(http, "http://127.0.0.1:9", timeout=2)
156
+ with pytest.raises(BrowserDaemonError):
157
+ await bd.overview()
158
+
159
+
160
+ def test_the_secret_is_read_from_its_file(tmp_path):
161
+ path = tmp_path / "secret"
162
+ path.write_text("abc\n")
163
+ assert read_secret(str(path)) == "abc"
164
+ assert read_secret(str(tmp_path / "missing")) is None