acob-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,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Local database
13
+ db.sqlite3
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: acob-client
3
+ Version: 0.1.0
4
+ Summary: Python client for Agent Controlled Browser
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # ACOB Python Client
9
+
10
+ The ACOB Python client controls one Chromium installation through an ACOB
11
+ server. It uses only the Python standard library.
12
+
13
+ Install it from the repository:
14
+
15
+ ```bash
16
+ pip install ./client
17
+ ```
18
+
19
+ Create a client with the browser ID shown in the extension popup. The endpoint
20
+ defaults to `http://127.0.0.1:58347`:
21
+
22
+ ```python
23
+ from pathlib import Path
24
+
25
+ from acob import ACOBClient
26
+
27
+ client = ACOBClient("0123456789ab4def8123456789abcdef")
28
+
29
+ tabs = client.tabs(operation="list")
30
+ tab = client.tabs(operation="navigate", url="https://example.com")
31
+ tid = tab["tid"]
32
+
33
+ client.click(tid, "a")
34
+ client.keyboard(tid, text="ACOB")
35
+ client.keyboard(tid, key="Enter")
36
+ title = client.javascript(tid, "document.title")
37
+
38
+ capture = client.screenshot(tid, full_page=True)
39
+ png = client.download_screenshot(capture["download_url"])
40
+ Path("screenshot.png").write_bytes(png)
41
+ ```
42
+
43
+ Use a different server and operation timeout when needed:
44
+
45
+ ```python
46
+ client = ACOBClient(
47
+ "0123456789ab4def8123456789abcdef",
48
+ endpoint="http://127.0.0.1:8000",
49
+ timeout=90,
50
+ )
51
+ ```
52
+
53
+ Action methods map directly to the API actions and payload fields. They submit
54
+ an instruction, poll until Chromium completes it, and return the action's
55
+ `result`.
56
+
57
+ The `tabs()` method mirrors the four tab operations:
58
+
59
+ ```python
60
+ tabs = client.tabs(operation="list")
61
+ tab = client.tabs(
62
+ operation="navigate",
63
+ tid=123,
64
+ url="https://example.com",
65
+ )
66
+ tab = client.tabs(operation="focus", tid=123)
67
+ closed = client.tabs(operation="close", tid=123)
68
+ ```
69
+
70
+ `screenshot()` returns the API's screenshot metadata unchanged. Its
71
+ `download_url` is single-use, so pass it directly to `download_screenshot()`
72
+ when ready to consume the PNG.
73
+
74
+ For lower-level queue control, use `submit()`, `wait()`, and `execute()`:
75
+
76
+ ```python
77
+ instruction = client.submit("tabs", operation="list")
78
+ terminal_response = client.wait(instruction["id"])
79
+
80
+ result = client.execute("tabs", operation="list")
81
+ ```
82
+
83
+ `wait()` returns the complete terminal response because that response is
84
+ single-use. `execute()` and the action helpers raise `ACOBInstructionError`
85
+ when Chromium reports a failed instruction. HTTP validation errors raise
86
+ `ACOBHTTPError`; connection, protocol, and timeout failures derive from
87
+ `ACOBError`.
88
+
89
+ If an operation times out, its accepted instruction can still finish on the
90
+ server. `ACOBTimeoutError.instruction_id` retains its ID so it can be passed to
91
+ `wait()` again.
@@ -0,0 +1,84 @@
1
+ # ACOB Python Client
2
+
3
+ The ACOB Python client controls one Chromium installation through an ACOB
4
+ server. It uses only the Python standard library.
5
+
6
+ Install it from the repository:
7
+
8
+ ```bash
9
+ pip install ./client
10
+ ```
11
+
12
+ Create a client with the browser ID shown in the extension popup. The endpoint
13
+ defaults to `http://127.0.0.1:58347`:
14
+
15
+ ```python
16
+ from pathlib import Path
17
+
18
+ from acob import ACOBClient
19
+
20
+ client = ACOBClient("0123456789ab4def8123456789abcdef")
21
+
22
+ tabs = client.tabs(operation="list")
23
+ tab = client.tabs(operation="navigate", url="https://example.com")
24
+ tid = tab["tid"]
25
+
26
+ client.click(tid, "a")
27
+ client.keyboard(tid, text="ACOB")
28
+ client.keyboard(tid, key="Enter")
29
+ title = client.javascript(tid, "document.title")
30
+
31
+ capture = client.screenshot(tid, full_page=True)
32
+ png = client.download_screenshot(capture["download_url"])
33
+ Path("screenshot.png").write_bytes(png)
34
+ ```
35
+
36
+ Use a different server and operation timeout when needed:
37
+
38
+ ```python
39
+ client = ACOBClient(
40
+ "0123456789ab4def8123456789abcdef",
41
+ endpoint="http://127.0.0.1:8000",
42
+ timeout=90,
43
+ )
44
+ ```
45
+
46
+ Action methods map directly to the API actions and payload fields. They submit
47
+ an instruction, poll until Chromium completes it, and return the action's
48
+ `result`.
49
+
50
+ The `tabs()` method mirrors the four tab operations:
51
+
52
+ ```python
53
+ tabs = client.tabs(operation="list")
54
+ tab = client.tabs(
55
+ operation="navigate",
56
+ tid=123,
57
+ url="https://example.com",
58
+ )
59
+ tab = client.tabs(operation="focus", tid=123)
60
+ closed = client.tabs(operation="close", tid=123)
61
+ ```
62
+
63
+ `screenshot()` returns the API's screenshot metadata unchanged. Its
64
+ `download_url` is single-use, so pass it directly to `download_screenshot()`
65
+ when ready to consume the PNG.
66
+
67
+ For lower-level queue control, use `submit()`, `wait()`, and `execute()`:
68
+
69
+ ```python
70
+ instruction = client.submit("tabs", operation="list")
71
+ terminal_response = client.wait(instruction["id"])
72
+
73
+ result = client.execute("tabs", operation="list")
74
+ ```
75
+
76
+ `wait()` returns the complete terminal response because that response is
77
+ single-use. `execute()` and the action helpers raise `ACOBInstructionError`
78
+ when Chromium reports a failed instruction. HTTP validation errors raise
79
+ `ACOBHTTPError`; connection, protocol, and timeout failures derive from
80
+ `ACOBError`.
81
+
82
+ If an operation times out, its accepted instruction can still finish on the
83
+ server. `ACOBTimeoutError.instruction_id` retains its ID so it can be passed to
84
+ `wait()` again.
@@ -0,0 +1,26 @@
1
+ from .client import (
2
+ DEFAULT_ENDPOINT,
3
+ ACOBClient,
4
+ ACOBConnectionError,
5
+ ACOBError,
6
+ ACOBHTTPError,
7
+ ACOBInstructionError,
8
+ ACOBProtocolError,
9
+ ACOBTimeoutError,
10
+ ScreenshotResult,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "DEFAULT_ENDPOINT",
17
+ "ACOBClient",
18
+ "ACOBConnectionError",
19
+ "ACOBError",
20
+ "ACOBHTTPError",
21
+ "ACOBInstructionError",
22
+ "ACOBProtocolError",
23
+ "ACOBTimeoutError",
24
+ "ScreenshotResult",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,430 @@
1
+ import json
2
+ import math
3
+ import time
4
+ from collections.abc import Sequence
5
+ from typing import TypeAlias, TypedDict, cast
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import SplitResult, urljoin, urlsplit
8
+ from urllib.request import Request, urlopen
9
+ from uuid import UUID
10
+
11
+ DEFAULT_ENDPOINT = "http://127.0.0.1:58347"
12
+
13
+ JsonValue: TypeAlias = (
14
+ bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
15
+ )
16
+ JsonObject: TypeAlias = dict[str, JsonValue]
17
+
18
+
19
+ class ScreenshotResult(TypedDict):
20
+ download_url: str
21
+ content_type: str
22
+ full_page: bool
23
+ single_use: bool
24
+ tid: int
25
+
26
+
27
+ class ACOBError(Exception):
28
+ """Base exception for ACOB client failures."""
29
+
30
+
31
+ class ACOBConnectionError(ACOBError):
32
+ """The ACOB server could not be reached."""
33
+
34
+
35
+ class ACOBProtocolError(ACOBError):
36
+ """The ACOB server returned an unexpected response."""
37
+
38
+
39
+ class ACOBHTTPError(ACOBError):
40
+ """The ACOB server rejected an HTTP request."""
41
+
42
+ def __init__(
43
+ self,
44
+ status_code: int,
45
+ message: str,
46
+ response: JsonObject | None = None,
47
+ ) -> None:
48
+ super().__init__(f"{message} (HTTP {status_code})")
49
+ self.status_code = status_code
50
+ self.response = response
51
+
52
+
53
+ class ACOBInstructionError(ACOBError):
54
+ """Chromium failed to execute an accepted instruction."""
55
+
56
+ def __init__(self, instruction_id: int, response: JsonObject) -> None:
57
+ message = response.get("error")
58
+ if not isinstance(message, str) or not message:
59
+ message = "Browser instruction failed"
60
+ super().__init__(message)
61
+ self.instruction_id = instruction_id
62
+ self.response = response
63
+
64
+
65
+ class ACOBTimeoutError(ACOBError):
66
+ """An instruction did not finish before the configured timeout."""
67
+
68
+ def __init__(self, instruction_id: int, timeout: float) -> None:
69
+ super().__init__(
70
+ f"Instruction {instruction_id} did not finish within {timeout:g} seconds"
71
+ )
72
+ self.instruction_id = instruction_id
73
+ self.timeout = timeout
74
+
75
+
76
+ class ACOBClient:
77
+ """Synchronous client for controlling one ACOB browser."""
78
+
79
+ _REQUEST_TIMEOUT = 10.0
80
+
81
+ def __init__(
82
+ self,
83
+ bid: str,
84
+ endpoint: str | None = None,
85
+ *,
86
+ timeout: float = 60.0,
87
+ poll_interval: float = 0.5,
88
+ ) -> None:
89
+ self.bid = self._validate_bid(bid)
90
+ self.endpoint = self._validate_endpoint(
91
+ DEFAULT_ENDPOINT if endpoint is None else endpoint
92
+ )
93
+ self.timeout = self._positive_float(timeout, "timeout")
94
+ self.poll_interval = self._positive_float(
95
+ poll_interval,
96
+ "poll_interval",
97
+ )
98
+ self._instructions_url = (
99
+ f"{self.endpoint}/api/browsers/{self.bid}/instructions"
100
+ )
101
+
102
+ def submit(self, action: str, /, **payload: JsonValue) -> JsonObject:
103
+ """Submit an instruction without waiting for Chromium to execute it."""
104
+ body = dict(payload)
105
+ body["action"] = action
106
+ return self._request_json(
107
+ "POST",
108
+ f"{self._instructions_url}/",
109
+ body,
110
+ timeout=min(self._REQUEST_TIMEOUT, self.timeout),
111
+ )
112
+
113
+ def wait(
114
+ self,
115
+ instruction_id: int,
116
+ *,
117
+ timeout: float | None = None,
118
+ ) -> JsonObject:
119
+ """Wait for and return an instruction's one-use terminal response."""
120
+ if (
121
+ isinstance(instruction_id, bool)
122
+ or not isinstance(instruction_id, int)
123
+ or instruction_id <= 0
124
+ ):
125
+ raise ValueError("instruction_id must be a positive integer")
126
+
127
+ wait_timeout = (
128
+ self.timeout
129
+ if timeout is None
130
+ else self._positive_float(timeout, "timeout")
131
+ )
132
+ deadline = time.monotonic() + wait_timeout
133
+ instruction_url = f"{self._instructions_url}/{instruction_id}/"
134
+
135
+ while True:
136
+ remaining = deadline - time.monotonic()
137
+ if remaining <= 0:
138
+ raise ACOBTimeoutError(instruction_id, wait_timeout)
139
+
140
+ response = self._request_json(
141
+ "GET",
142
+ instruction_url,
143
+ timeout=min(self._REQUEST_TIMEOUT, remaining),
144
+ )
145
+ status = response.get("status")
146
+ if status in {"completed", "failed"}:
147
+ return response
148
+ if status not in {"pending", "processing"}:
149
+ raise ACOBProtocolError(
150
+ f"Instruction {instruction_id} returned invalid status: {status!r}"
151
+ )
152
+
153
+ remaining = deadline - time.monotonic()
154
+ if remaining <= 0:
155
+ raise ACOBTimeoutError(instruction_id, wait_timeout)
156
+ time.sleep(min(self.poll_interval, remaining))
157
+
158
+ def execute(
159
+ self,
160
+ action: str,
161
+ /,
162
+ *,
163
+ timeout: float | None = None,
164
+ **payload: JsonValue,
165
+ ) -> JsonValue:
166
+ """Submit an action, wait for it, and return its browser result."""
167
+ instruction = self.submit(action, **payload)
168
+ instruction_id = instruction.get("id")
169
+ if (
170
+ isinstance(instruction_id, bool)
171
+ or not isinstance(instruction_id, int)
172
+ or instruction_id <= 0
173
+ ):
174
+ raise ACOBProtocolError("Created instruction did not contain a valid id")
175
+
176
+ terminal = self.wait(instruction_id, timeout=timeout)
177
+ if terminal.get("status") == "failed":
178
+ raise ACOBInstructionError(instruction_id, terminal)
179
+ return terminal.get("result")
180
+
181
+ def tabs(
182
+ self,
183
+ operation: str,
184
+ *,
185
+ tid: int | None = None,
186
+ url: str | None = None,
187
+ timeout: float | None = None,
188
+ ) -> JsonValue:
189
+ """Run a list, navigate, focus, or close tab operation."""
190
+ payload: JsonObject = {"operation": operation}
191
+ if tid is not None:
192
+ payload["tid"] = tid
193
+ if url is not None:
194
+ payload["url"] = url
195
+ return self.execute("tabs", timeout=timeout, **payload)
196
+
197
+ def click(
198
+ self,
199
+ tid: int,
200
+ selector: str,
201
+ *,
202
+ timeout: float | None = None,
203
+ ) -> JsonObject:
204
+ """Click the center of the element matching a CSS selector."""
205
+ return self._expect_object(
206
+ self.execute(
207
+ "click",
208
+ tid=tid,
209
+ selector=selector,
210
+ timeout=timeout,
211
+ ),
212
+ "click",
213
+ )
214
+
215
+ def keyboard(
216
+ self,
217
+ tid: int,
218
+ *,
219
+ text: str | None = None,
220
+ key: str | None = None,
221
+ modifiers: Sequence[str] | None = None,
222
+ timeout: float | None = None,
223
+ ) -> JsonObject:
224
+ """Insert text or dispatch one key to the focused page control."""
225
+ payload: JsonObject = {"tid": tid}
226
+ if text is not None:
227
+ payload["text"] = text
228
+ if key is not None:
229
+ payload["key"] = key
230
+ if modifiers is not None:
231
+ payload["modifiers"] = list(modifiers)
232
+ return self._expect_object(
233
+ self.execute("keyboard", timeout=timeout, **payload),
234
+ "keyboard",
235
+ )
236
+
237
+ def screenshot(
238
+ self,
239
+ tid: int,
240
+ *,
241
+ full_page: bool = False,
242
+ timeout: float | None = None,
243
+ ) -> ScreenshotResult:
244
+ """Capture a tab and return its one-use download metadata."""
245
+ result = self._expect_object(
246
+ self.execute(
247
+ "screenshot",
248
+ tid=tid,
249
+ full_page=full_page,
250
+ timeout=timeout,
251
+ ),
252
+ "screenshot",
253
+ )
254
+ return cast(ScreenshotResult, result)
255
+
256
+ def download_screenshot(self, download_url: str) -> bytes:
257
+ """Consume a screenshot URL returned by a low-level execute call."""
258
+ if not isinstance(download_url, str) or not download_url:
259
+ raise ValueError("download_url must be a non-empty string")
260
+
261
+ resolved_url = urljoin(f"{self.endpoint}/", download_url)
262
+ if self._origin(urlsplit(resolved_url)) != self._origin(
263
+ urlsplit(self.endpoint)
264
+ ):
265
+ raise ACOBProtocolError(
266
+ "Screenshot download URL points to a different server"
267
+ )
268
+ return self._request_bytes(
269
+ "GET",
270
+ resolved_url,
271
+ timeout=min(self._REQUEST_TIMEOUT, self.timeout),
272
+ accept="image/png",
273
+ )
274
+
275
+ def javascript(
276
+ self,
277
+ tid: int,
278
+ script: str,
279
+ *,
280
+ timeout: float | None = None,
281
+ ) -> JsonValue:
282
+ """Evaluate JavaScript in a tab and return its JSON-compatible value."""
283
+ return self.execute(
284
+ "javascript",
285
+ tid=tid,
286
+ script=script,
287
+ timeout=timeout,
288
+ )
289
+
290
+ def _request_json(
291
+ self,
292
+ method: str,
293
+ url: str,
294
+ body: JsonObject | None = None,
295
+ *,
296
+ timeout: float,
297
+ ) -> JsonObject:
298
+ raw = self._request_bytes(method, url, body, timeout=timeout)
299
+ try:
300
+ parsed: object = json.loads(raw.decode("utf-8"))
301
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
302
+ raise ACOBProtocolError(
303
+ f"{method} {url} returned invalid JSON"
304
+ ) from error
305
+ if not isinstance(parsed, dict):
306
+ raise ACOBProtocolError(
307
+ f"{method} {url} returned a non-object JSON response"
308
+ )
309
+ return cast(JsonObject, parsed)
310
+
311
+ def _request_bytes(
312
+ self,
313
+ method: str,
314
+ url: str,
315
+ body: JsonObject | None = None,
316
+ *,
317
+ timeout: float,
318
+ accept: str = "application/json",
319
+ ) -> bytes:
320
+ headers = {"Accept": accept}
321
+ data = None
322
+ if body is not None:
323
+ headers["Content-Type"] = "application/json"
324
+ data = json.dumps(
325
+ body,
326
+ ensure_ascii=False,
327
+ separators=(",", ":"),
328
+ ).encode("utf-8")
329
+ request = Request(url, data=data, headers=headers, method=method)
330
+
331
+ try:
332
+ with urlopen(request, timeout=timeout) as response:
333
+ return response.read()
334
+ except HTTPError as error:
335
+ try:
336
+ response_body = error.read()
337
+ finally:
338
+ error.close()
339
+ parsed = self._try_parse_object(response_body)
340
+ message = self._http_error_message(parsed)
341
+ raise ACOBHTTPError(error.code, message, parsed) from None
342
+ except (URLError, TimeoutError, OSError) as error:
343
+ reason = getattr(error, "reason", error)
344
+ raise ACOBConnectionError(
345
+ f"Could not connect to ACOB at {self.endpoint}: {reason}"
346
+ ) from error
347
+
348
+ @staticmethod
349
+ def _expect_object(result: JsonValue, action: str) -> JsonObject:
350
+ if not isinstance(result, dict):
351
+ raise ACOBProtocolError(f"{action} returned an invalid result")
352
+ return result
353
+
354
+ @staticmethod
355
+ def _try_parse_object(body: bytes) -> JsonObject | None:
356
+ try:
357
+ parsed: object = json.loads(body.decode("utf-8"))
358
+ except (UnicodeDecodeError, json.JSONDecodeError):
359
+ return None
360
+ return cast(JsonObject, parsed) if isinstance(parsed, dict) else None
361
+
362
+ @staticmethod
363
+ def _http_error_message(response: JsonObject | None) -> str:
364
+ if response is None:
365
+ return "ACOB request failed"
366
+ message = response.get("error")
367
+ if not isinstance(message, str) or not message:
368
+ return "ACOB request failed"
369
+
370
+ details = response.get("details")
371
+ if not isinstance(details, list):
372
+ return message
373
+ rendered_details = []
374
+ for detail in details:
375
+ if not isinstance(detail, dict):
376
+ continue
377
+ field = detail.get("field")
378
+ detail_message = detail.get("message")
379
+ if isinstance(field, str) and isinstance(detail_message, str):
380
+ rendered_details.append(f"{field}: {detail_message}")
381
+ return (
382
+ f"{message}: {'; '.join(rendered_details)}"
383
+ if rendered_details
384
+ else message
385
+ )
386
+
387
+ @staticmethod
388
+ def _validate_bid(bid: str) -> str:
389
+ try:
390
+ parsed = UUID(bid)
391
+ except (ValueError, TypeError, AttributeError) as error:
392
+ raise ValueError(
393
+ "bid must be a lowercase dashless UUIDv4"
394
+ ) from error
395
+ if parsed.hex != bid or parsed.version != 4:
396
+ raise ValueError("bid must be a lowercase dashless UUIDv4")
397
+ return bid
398
+
399
+ @staticmethod
400
+ def _validate_endpoint(endpoint: str) -> str:
401
+ if not isinstance(endpoint, str) or not endpoint:
402
+ raise ValueError("endpoint must be a non-empty HTTP or HTTPS URL")
403
+ normalized = endpoint.rstrip("/")
404
+ try:
405
+ parsed = urlsplit(normalized)
406
+ _ = parsed.port
407
+ except ValueError as error:
408
+ raise ValueError("endpoint must be a valid HTTP or HTTPS URL") from error
409
+ if (
410
+ parsed.scheme not in {"http", "https"}
411
+ or parsed.hostname is None
412
+ or parsed.query
413
+ or parsed.fragment
414
+ ):
415
+ raise ValueError("endpoint must be a valid HTTP or HTTPS URL")
416
+ return normalized
417
+
418
+ @staticmethod
419
+ def _origin(url: SplitResult) -> tuple[str, str | None, int | None]:
420
+ default_port = 443 if url.scheme == "https" else 80
421
+ return url.scheme.lower(), url.hostname, url.port or default_port
422
+
423
+ @staticmethod
424
+ def _positive_float(value: float, name: str) -> float:
425
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
426
+ raise ValueError(f"{name} must be a positive number")
427
+ converted = float(value)
428
+ if not math.isfinite(converted) or converted <= 0:
429
+ raise ValueError(f"{name} must be a positive number")
430
+ return converted
File without changes
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "acob-client"
7
+ version = "0.1.0"
8
+ description = "Python client for Agent Controlled Browser"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["acob"]
15
+
16
+ [tool.ruff]
17
+ target-version = "py310"
18
+
19
+ [tool.ruff.lint]
20
+ select = ["B", "E", "F", "I", "RUF", "UP"]
21
+
22
+ [tool.pyright]
23
+ include = ["acob", "tests"]
24
+ pythonVersion = "3.10"
@@ -0,0 +1,281 @@
1
+ import json
2
+ import unittest
3
+ from email.message import Message
4
+ from io import BytesIO
5
+ from unittest.mock import call, patch
6
+ from urllib.error import HTTPError
7
+ from urllib.request import Request
8
+
9
+ from acob import (
10
+ DEFAULT_ENDPOINT,
11
+ ACOBClient,
12
+ ACOBHTTPError,
13
+ ACOBInstructionError,
14
+ ACOBProtocolError,
15
+ ACOBTimeoutError,
16
+ )
17
+
18
+
19
+ class FakeResponse:
20
+ def __init__(self, body):
21
+ self.body = (
22
+ body
23
+ if isinstance(body, bytes)
24
+ else json.dumps(body).encode("utf-8")
25
+ )
26
+
27
+ def __enter__(self):
28
+ return self
29
+
30
+ def __exit__(self, _exc_type, _exc_value, _traceback):
31
+ return False
32
+
33
+ def read(self):
34
+ return self.body
35
+
36
+
37
+ class ACOBClientTests(unittest.TestCase):
38
+ BID = "0123456789ab4def8123456789abcdef"
39
+
40
+ def make_client(self, endpoint="http://acob.test/"):
41
+ return ACOBClient(
42
+ self.BID,
43
+ endpoint=endpoint,
44
+ timeout=5,
45
+ poll_interval=0.01,
46
+ )
47
+
48
+ def test_initializes_with_default_endpoint_and_validates_configuration(self):
49
+ client = ACOBClient(self.BID)
50
+
51
+ self.assertEqual(client.bid, self.BID)
52
+ self.assertEqual(client.endpoint, DEFAULT_ENDPOINT)
53
+
54
+ invalid_bids = (
55
+ "not-a-uuid",
56
+ "00000000000000000000000000000000",
57
+ "01234567-89ab-4def-8123-456789abcdef",
58
+ "0123456789AB4DEF8123456789ABCDEF",
59
+ )
60
+ for bid in invalid_bids:
61
+ with self.subTest(bid=bid), self.assertRaises(ValueError):
62
+ ACOBClient(bid)
63
+
64
+ for endpoint in ("", "acob.test", "ftp://acob.test", "http://acob.test?q=1"):
65
+ with self.subTest(endpoint=endpoint), self.assertRaises(ValueError):
66
+ ACOBClient(self.BID, endpoint)
67
+
68
+ def test_tabs_submits_and_consumes_terminal_response(self):
69
+ tab = {
70
+ "tid": 12,
71
+ "window_id": 3,
72
+ "active": True,
73
+ "focused": True,
74
+ "title": "Example",
75
+ "url": "https://example.com/",
76
+ "domain": "example.com",
77
+ }
78
+ responses = [
79
+ FakeResponse({"id": 7, "status": "pending"}),
80
+ FakeResponse({"id": 7, "status": "processing"}),
81
+ FakeResponse({"id": 7, "status": "completed", "result": [tab]}),
82
+ ]
83
+
84
+ with (
85
+ patch("acob.client.urlopen", side_effect=responses) as mocked_urlopen,
86
+ patch("acob.client.time.sleep") as mocked_sleep,
87
+ ):
88
+ result = self.make_client().tabs(operation="list")
89
+
90
+ self.assertEqual(result, [tab])
91
+ self.assertEqual(mocked_urlopen.call_count, 3)
92
+ self.assertEqual(mocked_sleep.call_count, 1)
93
+
94
+ submitted_request = mocked_urlopen.call_args_list[0].args[0]
95
+ self.assertIsInstance(submitted_request, Request)
96
+ self.assertEqual(submitted_request.get_method(), "POST")
97
+ self.assertEqual(
98
+ submitted_request.full_url,
99
+ f"http://acob.test/api/browsers/{self.BID}/instructions/",
100
+ )
101
+ self.assertEqual(
102
+ json.loads(submitted_request.data),
103
+ {"action": "tabs", "operation": "list"},
104
+ )
105
+
106
+ terminal_request = mocked_urlopen.call_args_list[2].args[0]
107
+ self.assertEqual(terminal_request.get_method(), "GET")
108
+ self.assertEqual(
109
+ terminal_request.full_url,
110
+ f"http://acob.test/api/browsers/{self.BID}/instructions/7/",
111
+ )
112
+
113
+ def test_action_methods_send_the_supported_api_payloads(self):
114
+ client = self.make_client()
115
+
116
+ with patch.object(client, "execute", return_value={}) as execute:
117
+ client.tabs(operation="list")
118
+ client.tabs(operation="navigate", url="https://example.com")
119
+ client.tabs(
120
+ operation="navigate",
121
+ tid=10,
122
+ url="https://example.org",
123
+ )
124
+ client.tabs(operation="focus", tid=10)
125
+ client.tabs(operation="close", tid=10)
126
+ client.click(10, "button[type=submit]")
127
+ client.keyboard(10, text="ACOB")
128
+ client.keyboard(10, key="Enter", modifiers=["ctrl", "shift"])
129
+ client.javascript(10, "document.title")
130
+
131
+ self.assertEqual(
132
+ execute.call_args_list,
133
+ [
134
+ call("tabs", timeout=None, operation="list"),
135
+ call(
136
+ "tabs",
137
+ timeout=None,
138
+ operation="navigate",
139
+ url="https://example.com",
140
+ ),
141
+ call(
142
+ "tabs",
143
+ timeout=None,
144
+ operation="navigate",
145
+ tid=10,
146
+ url="https://example.org",
147
+ ),
148
+ call("tabs", timeout=None, operation="focus", tid=10),
149
+ call("tabs", timeout=None, operation="close", tid=10),
150
+ call(
151
+ "click",
152
+ tid=10,
153
+ selector="button[type=submit]",
154
+ timeout=None,
155
+ ),
156
+ call("keyboard", timeout=None, tid=10, text="ACOB"),
157
+ call(
158
+ "keyboard",
159
+ timeout=None,
160
+ tid=10,
161
+ key="Enter",
162
+ modifiers=["ctrl", "shift"],
163
+ ),
164
+ call(
165
+ "javascript",
166
+ tid=10,
167
+ script="document.title",
168
+ timeout=None,
169
+ ),
170
+ ],
171
+ )
172
+
173
+ def test_failed_instruction_raises_with_the_consumed_response(self):
174
+ terminal = {
175
+ "id": 4,
176
+ "status": "failed",
177
+ "result": None,
178
+ "error": "No element matches selector: button",
179
+ }
180
+ with patch(
181
+ "acob.client.urlopen",
182
+ side_effect=[
183
+ FakeResponse({"id": 4, "status": "pending"}),
184
+ FakeResponse(terminal),
185
+ ],
186
+ ):
187
+ with self.assertRaises(ACOBInstructionError) as raised:
188
+ self.make_client().click(12, "button")
189
+
190
+ self.assertEqual(raised.exception.instruction_id, 4)
191
+ self.assertEqual(raised.exception.response, terminal)
192
+ self.assertEqual(str(raised.exception), terminal["error"])
193
+
194
+ def test_http_validation_error_exposes_status_and_response(self):
195
+ error_body = {
196
+ "error": "Invalid request",
197
+ "details": [
198
+ {
199
+ "field": "javascript.script",
200
+ "message": "String should have at least 1 character",
201
+ "type": "string_too_short",
202
+ }
203
+ ],
204
+ }
205
+ http_error = HTTPError(
206
+ "http://acob.test/instructions/",
207
+ 400,
208
+ "Bad Request",
209
+ Message(),
210
+ BytesIO(json.dumps(error_body).encode("utf-8")),
211
+ )
212
+
213
+ with patch("acob.client.urlopen", side_effect=http_error):
214
+ with self.assertRaises(ACOBHTTPError) as raised:
215
+ self.make_client().javascript(12, "")
216
+
217
+ self.assertEqual(raised.exception.status_code, 400)
218
+ self.assertEqual(raised.exception.response, error_body)
219
+ self.assertIn("javascript.script", str(raised.exception))
220
+
221
+ def test_screenshot_returns_metadata_for_an_explicit_one_use_download(self):
222
+ image = b"\x89PNG\r\n\x1a\nACOB"
223
+ download_url = f"/api/browsers/{self.BID}/screenshots/9/"
224
+ responses = [
225
+ FakeResponse({"id": 8, "status": "pending"}),
226
+ FakeResponse(
227
+ {
228
+ "id": 8,
229
+ "status": "completed",
230
+ "result": {
231
+ "download_url": download_url,
232
+ "content_type": "image/png",
233
+ "full_page": True,
234
+ "single_use": True,
235
+ "tid": 12,
236
+ },
237
+ }
238
+ ),
239
+ FakeResponse(image),
240
+ ]
241
+
242
+ with patch(
243
+ "acob.client.urlopen",
244
+ side_effect=responses,
245
+ ) as mocked_urlopen:
246
+ client = self.make_client()
247
+ result = client.screenshot(12, full_page=True)
248
+ image_result = client.download_screenshot(result["download_url"])
249
+
250
+ self.assertEqual(result["download_url"], download_url)
251
+ self.assertEqual(image_result, image)
252
+ submitted_request = mocked_urlopen.call_args_list[0].args[0]
253
+ self.assertEqual(
254
+ json.loads(submitted_request.data),
255
+ {"action": "screenshot", "tid": 12, "full_page": True},
256
+ )
257
+ download_request = mocked_urlopen.call_args_list[2].args[0]
258
+ self.assertEqual(download_request.full_url, f"http://acob.test{download_url}")
259
+ self.assertEqual(download_request.get_method(), "GET")
260
+
261
+ def test_screenshot_rejects_a_download_on_another_origin(self):
262
+ with self.assertRaises(ACOBProtocolError):
263
+ self.make_client().download_screenshot("https://example.com/image.png")
264
+
265
+ def test_timeout_retains_instruction_id_for_later_recovery(self):
266
+ with (
267
+ patch(
268
+ "acob.client.urlopen",
269
+ return_value=FakeResponse({"id": 21, "status": "pending"}),
270
+ ),
271
+ patch("acob.client.time.monotonic", side_effect=[0, 0, 1]),
272
+ ):
273
+ with self.assertRaises(ACOBTimeoutError) as raised:
274
+ self.make_client().wait(21, timeout=1)
275
+
276
+ self.assertEqual(raised.exception.instruction_id, 21)
277
+ self.assertEqual(raised.exception.timeout, 1)
278
+
279
+
280
+ if __name__ == "__main__":
281
+ unittest.main()