use-computer-cli 0.1.0__tar.gz → 0.1.1__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.
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/PKG-INFO +5 -2
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/README.md +4 -1
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/pyproject.toml +1 -1
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/actions.py +3 -2
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/cli.py +6 -4
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/compare.py +18 -9
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/config.py +16 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/runner.py +26 -32
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/skill/SKILL.md +19 -2
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/conftest.py +21 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_cli.py +3 -3
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_config.py +33 -1
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_config_init.py +3 -3
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_runner.py +44 -3
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/.gitignore +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/LICENSE +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/__init__.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/__init__.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/base.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/local.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/vnc.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/coordinates.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/errors.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/keys.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/skill/__init__.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/__init__.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/fake_backend.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_backends.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_compare.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_coordinates.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_keys.py +0 -0
- {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_skill.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: use-computer-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot.
|
|
5
5
|
Project-URL: Homepage, https://github.com/applica-software-guru/use-computer
|
|
6
6
|
Author: Bruno Fortunato
|
|
@@ -83,7 +83,7 @@ project. The command it installs is `use-computer`, and the package it imports i
|
|
|
83
83
|
use-computer click --x 120 --y 340 --use staging
|
|
84
84
|
use-computer type --text "hello" --use staging
|
|
85
85
|
use-computer key ctrl+s --use staging
|
|
86
|
-
use-computer screenshot --use laptop
|
|
86
|
+
use-computer screenshot --use laptop # writes a file, returns its path
|
|
87
87
|
|
|
88
88
|
# a batch runs over one connection -- the default command, so `batch` may be omitted
|
|
89
89
|
echo '[{"action":"click","x":120,"y":340},{"action":"key","combo":"enter"}]' \
|
|
@@ -93,6 +93,9 @@ echo '[{"action":"click","x":120,"y":340},{"action":"key","combo":"enter"}]' \
|
|
|
93
93
|
stdout is one JSON object per run; every diagnostic goes to stderr. Exit codes: `0` success,
|
|
94
94
|
`1` failure, `2` bad usage.
|
|
95
95
|
|
|
96
|
+
Screenshots are files, never bytes in the JSON. `--verify` writes the screen it captured after the
|
|
97
|
+
action and reports the path, so a verified action does not need a `screenshot` call after it.
|
|
98
|
+
|
|
96
99
|
## Three problems it solves
|
|
97
100
|
|
|
98
101
|
- **Coordinate spaces.** A screenshot on a HiDPI display is larger than the space the OS clicks
|
|
@@ -27,7 +27,7 @@ project. The command it installs is `use-computer`, and the package it imports i
|
|
|
27
27
|
use-computer click --x 120 --y 340 --use staging
|
|
28
28
|
use-computer type --text "hello" --use staging
|
|
29
29
|
use-computer key ctrl+s --use staging
|
|
30
|
-
use-computer screenshot --use laptop
|
|
30
|
+
use-computer screenshot --use laptop # writes a file, returns its path
|
|
31
31
|
|
|
32
32
|
# a batch runs over one connection -- the default command, so `batch` may be omitted
|
|
33
33
|
echo '[{"action":"click","x":120,"y":340},{"action":"key","combo":"enter"}]' \
|
|
@@ -37,6 +37,9 @@ echo '[{"action":"click","x":120,"y":340},{"action":"key","combo":"enter"}]' \
|
|
|
37
37
|
stdout is one JSON object per run; every diagnostic goes to stderr. Exit codes: `0` success,
|
|
38
38
|
`1` failure, `2` bad usage.
|
|
39
39
|
|
|
40
|
+
Screenshots are files, never bytes in the JSON. `--verify` writes the screen it captured after the
|
|
41
|
+
action and reports the path, so a verified action does not need a `screenshot` call after it.
|
|
42
|
+
|
|
40
43
|
## Three problems it solves
|
|
41
44
|
|
|
42
45
|
- **Coordinate spaces.** A screenshot on a HiDPI display is larger than the space the OS clicks
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "use-computer-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Execute input on a screen for computer-use agents: move, click, drag, scroll, type, key, screenshot."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -138,8 +138,9 @@ class KeyAction(BaseAction):
|
|
|
138
138
|
|
|
139
139
|
class ScreenshotAction(BaseAction):
|
|
140
140
|
action: Literal["screenshot"] = "screenshot"
|
|
141
|
-
out: Path | None =
|
|
142
|
-
|
|
141
|
+
out: Path | None = Field(
|
|
142
|
+
default=None, description="Where to write it. None means the screenshot directory."
|
|
143
|
+
)
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
Action = Annotated[
|
|
@@ -323,14 +323,16 @@ def key(
|
|
|
323
323
|
|
|
324
324
|
@app.command()
|
|
325
325
|
def screenshot(
|
|
326
|
-
out: Annotated[
|
|
327
|
-
|
|
326
|
+
out: Annotated[
|
|
327
|
+
Path | None,
|
|
328
|
+
typer.Option("--out", help="Write the PNG here. Otherwise the screenshot directory."),
|
|
329
|
+
] = None,
|
|
328
330
|
use: UseOption = None,
|
|
329
331
|
verbose: VerboseOption = 0,
|
|
330
332
|
) -> None:
|
|
331
|
-
"""Capture the current screen."""
|
|
333
|
+
"""Capture the current screen to a file and report its path."""
|
|
332
334
|
config = _config(use, verbose=verbose)
|
|
333
|
-
_run([ScreenshotAction(out=out
|
|
335
|
+
_run([ScreenshotAction(out=out)], config, verbose)
|
|
334
336
|
|
|
335
337
|
|
|
336
338
|
@app.command()
|
|
@@ -10,7 +10,6 @@ downscaled greyscale images. This module is pure: it takes images and returns a
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
import base64
|
|
14
13
|
import io
|
|
15
14
|
from datetime import datetime, timezone
|
|
16
15
|
from pathlib import Path
|
|
@@ -31,12 +30,17 @@ PIXEL_DELTA = 16
|
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
class Screenshot(BaseModel):
|
|
34
|
-
"""A captured screen.
|
|
33
|
+
"""A captured screen.
|
|
34
|
+
|
|
35
|
+
``data`` is held only while a comparison needs it. It is excluded from serialisation: a
|
|
36
|
+
screenshot that reaches the calling agent is a path, never a megabyte of base64 in its
|
|
37
|
+
context.
|
|
38
|
+
"""
|
|
35
39
|
|
|
36
40
|
model_config = ConfigDict(frozen=True)
|
|
37
41
|
|
|
38
42
|
path: Path | None = None
|
|
39
|
-
data: bytes | None = Field(default=None, repr=False)
|
|
43
|
+
data: bytes | None = Field(default=None, repr=False, exclude=True)
|
|
40
44
|
width: int
|
|
41
45
|
height: int
|
|
42
46
|
space: CoordinateSpace = CoordinateSpace.SCREENSHOT
|
|
@@ -50,13 +54,18 @@ class Screenshot(BaseModel):
|
|
|
50
54
|
return Image.open(self.path)
|
|
51
55
|
raise ValueError("screenshot has neither data nor path")
|
|
52
56
|
|
|
53
|
-
def
|
|
54
|
-
"""
|
|
57
|
+
def write_to(self, path: Path) -> Screenshot:
|
|
58
|
+
"""Write the PNG to ``path`` and return the screenshot that knows where it lives."""
|
|
59
|
+
if self.path == path:
|
|
60
|
+
return self
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
62
|
if self.data is not None:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
path.write_bytes(self.data)
|
|
64
|
+
elif self.path is not None:
|
|
65
|
+
path.write_bytes(self.path.read_bytes())
|
|
66
|
+
else:
|
|
67
|
+
raise ValueError("screenshot has neither data nor path")
|
|
68
|
+
return self.model_copy(update={"path": path})
|
|
60
69
|
|
|
61
70
|
|
|
62
71
|
class ChangeReport(BaseModel):
|
|
@@ -72,6 +72,10 @@ class Settings(BaseSettings):
|
|
|
72
72
|
verify: bool = False
|
|
73
73
|
verify_threshold: float = DEFAULT_THRESHOLD
|
|
74
74
|
space: CoordinateSpace = CoordinateSpace.SCREENSHOT
|
|
75
|
+
screenshot_dir: Path | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Where screenshots go when no path was given. None means the XDG data dir.",
|
|
78
|
+
)
|
|
75
79
|
dry_run: bool = False
|
|
76
80
|
allow_local: bool = False
|
|
77
81
|
continue_on_error: bool = False
|
|
@@ -197,6 +201,15 @@ def xdg_data_dir() -> Path:
|
|
|
197
201
|
return (Path(base) if base else Path.home() / ".local" / "share") / "use-computer"
|
|
198
202
|
|
|
199
203
|
|
|
204
|
+
def default_screenshot_dir() -> Path:
|
|
205
|
+
"""Where screenshots land when nothing said otherwise.
|
|
206
|
+
|
|
207
|
+
The XDG *data* directory: a screenshot of somebody's desktop is not disposable like a cache,
|
|
208
|
+
and it does not belong in a working tree.
|
|
209
|
+
"""
|
|
210
|
+
return xdg_data_dir() / "screenshots"
|
|
211
|
+
|
|
212
|
+
|
|
200
213
|
def env_var_for(field: str) -> str:
|
|
201
214
|
return ENV_PREFIX + field.upper()
|
|
202
215
|
|
|
@@ -505,6 +518,7 @@ def write_initial_config(
|
|
|
505
518
|
_TRUE = {"1", "true", "yes", "on"}
|
|
506
519
|
_FALSE = {"0", "false", "no", "off"}
|
|
507
520
|
_BOOL_FIELDS = frozenset({"verify", "dry_run", "allow_local", "continue_on_error"})
|
|
521
|
+
_PATH_FIELDS = frozenset({"screenshot_dir"})
|
|
508
522
|
_FLOAT_FIELDS = frozenset({"delay", "typing_rate", "verify_threshold", "scale"})
|
|
509
523
|
|
|
510
524
|
|
|
@@ -524,6 +538,8 @@ def _coerce(field: str, value: Any) -> Any:
|
|
|
524
538
|
return float(value)
|
|
525
539
|
except ValueError as exc:
|
|
526
540
|
raise ConfigError(f"{env_var_for(field)}={value!r} is not a number") from exc
|
|
541
|
+
if field in _PATH_FIELDS:
|
|
542
|
+
return Path(value).expanduser()
|
|
527
543
|
if field == "port":
|
|
528
544
|
try:
|
|
529
545
|
return int(value)
|
|
@@ -9,6 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import time
|
|
11
11
|
from collections.abc import Sequence
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
12
14
|
from types import TracebackType
|
|
13
15
|
from typing import Any
|
|
14
16
|
|
|
@@ -30,7 +32,12 @@ from use_computer.actions import (
|
|
|
30
32
|
)
|
|
31
33
|
from use_computer.backends import Backend, create_backend
|
|
32
34
|
from use_computer.compare import ChangeReport, Screenshot, compare
|
|
33
|
-
from use_computer.config import
|
|
35
|
+
from use_computer.config import (
|
|
36
|
+
BackendProfile,
|
|
37
|
+
ResolvedConfig,
|
|
38
|
+
Settings,
|
|
39
|
+
default_screenshot_dir,
|
|
40
|
+
)
|
|
34
41
|
from use_computer.config import load as load_config
|
|
35
42
|
from use_computer.coordinates import Coordinate, ScreenInfo
|
|
36
43
|
from use_computer.errors import UseComputerError
|
|
@@ -98,6 +105,7 @@ class Session:
|
|
|
98
105
|
self._backend = backend
|
|
99
106
|
self._profile = profile
|
|
100
107
|
self._settings = settings or Settings()
|
|
108
|
+
self._screenshot_dir = self._settings.screenshot_dir or default_screenshot_dir()
|
|
101
109
|
self._screen = backend.screen_info()
|
|
102
110
|
|
|
103
111
|
@classmethod
|
|
@@ -199,8 +207,15 @@ class Session:
|
|
|
199
207
|
after = self._safe_screenshot()
|
|
200
208
|
if before is not None and after is not None:
|
|
201
209
|
change = compare(before, after, settings.verify_threshold)
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
if isinstance(action, ScreenshotAction):
|
|
211
|
+
# The action already captured and wrote one; do not write it twice.
|
|
212
|
+
pass
|
|
213
|
+
elif after is not None:
|
|
214
|
+
# The capture is paid for either way. Writing it down is what saves the
|
|
215
|
+
# calling agent a round trip for a screen it already has.
|
|
216
|
+
screenshot = after.write_to(
|
|
217
|
+
self._screenshot_path(action.action)
|
|
218
|
+
)
|
|
204
219
|
except UseComputerError as exc:
|
|
205
220
|
error = ErrorInfo.of(exc)
|
|
206
221
|
except Exception as exc: # a backend can fail in its own vocabulary
|
|
@@ -248,14 +263,12 @@ class Session:
|
|
|
248
263
|
|
|
249
264
|
def _capture(self, action: ScreenshotAction) -> Screenshot:
|
|
250
265
|
shot = self._backend.screenshot()
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
shot = shot.model_copy(update={"data": None if action.out else shot.data})
|
|
258
|
-
return shot
|
|
266
|
+
return shot.write_to(action.out or self._screenshot_path("screenshot"))
|
|
267
|
+
|
|
268
|
+
def _screenshot_path(self, label: str) -> Path:
|
|
269
|
+
"""A name that sorts and does not collide."""
|
|
270
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f")[:-3] + "Z"
|
|
271
|
+
return self._screenshot_dir / f"{stamp}-{label}.png"
|
|
259
272
|
|
|
260
273
|
def _safe_screenshot(self) -> Screenshot | None:
|
|
261
274
|
"""Change detection is advisory; a capture that fails must not fail the action."""
|
|
@@ -280,25 +293,6 @@ def run_actions(
|
|
|
280
293
|
|
|
281
294
|
|
|
282
295
|
def as_json(result: RunResult) -> dict[str, Any]:
|
|
283
|
-
"""The run payload
|
|
284
|
-
payload = result.model_dump(mode="json"
|
|
285
|
-
payload["results"] = [
|
|
286
|
-
{**item, "screenshot": _screenshot_json(action.screenshot)}
|
|
287
|
-
for item, action in zip(payload["results"], result.results, strict=True)
|
|
288
|
-
]
|
|
289
|
-
return payload
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def _screenshot_json(shot: Screenshot | None) -> dict[str, Any] | None:
|
|
293
|
-
if shot is None:
|
|
294
|
-
return None
|
|
295
|
-
payload: dict[str, Any] = {
|
|
296
|
-
"path": str(shot.path) if shot.path else None,
|
|
297
|
-
"width": shot.width,
|
|
298
|
-
"height": shot.height,
|
|
299
|
-
"space": shot.space.value,
|
|
300
|
-
"captured_at": shot.captured_at.isoformat(),
|
|
301
|
-
}
|
|
302
|
-
if shot.data is not None:
|
|
303
|
-
payload["base64"] = shot.base64()
|
|
296
|
+
"""The run payload. A screenshot is a path here, never bytes."""
|
|
297
|
+
payload: dict[str, Any] = result.model_dump(mode="json")
|
|
304
298
|
return payload
|
|
@@ -40,7 +40,7 @@ use-computer drag --from-x 10 --from-y 20 --to-x 300 --to-y 400 --use staging
|
|
|
40
40
|
use-computer scroll --amount 3 --direction down --use staging
|
|
41
41
|
use-computer type --text "hello world" --use staging
|
|
42
42
|
use-computer key ctrl+s --use staging
|
|
43
|
-
use-computer screenshot --
|
|
43
|
+
use-computer screenshot --use staging # writes a file, returns its path
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
`type` sends literal text. `ctrl+a` given to `type` types seven characters — use `key` for
|
|
@@ -70,7 +70,20 @@ echo '[
|
|
|
70
70
|
batch stops at the first failure and reports `failed_index`, so you can resume from a known
|
|
71
71
|
point. `--continue-on-error` runs the rest anyway.
|
|
72
72
|
|
|
73
|
-
##
|
|
73
|
+
## Screenshots are files
|
|
74
|
+
|
|
75
|
+
A screenshot is never returned to you as bytes. It is written to a file and you get the path:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
"screenshot": {"path": "/home/you/.local/share/use-computer/screenshots/20260904T103012.481Z-click.png",
|
|
79
|
+
"width": 2560, "height": 1600, "space": "screenshot"}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Read the file when you actually need to look at the screen. Do not ask for the pixels by default —
|
|
83
|
+
a batch of verified clicks would otherwise bury your context in base64, which is why that option
|
|
84
|
+
does not exist.
|
|
85
|
+
|
|
86
|
+
## Verify — how you know it worked, and what the screen looks like now
|
|
74
87
|
|
|
75
88
|
A click that lands on nothing looks exactly like a click that worked. With `--verify` (or
|
|
76
89
|
`"verify": true` on one action) each action reports:
|
|
@@ -79,6 +92,10 @@ A click that lands on nothing looks exactly like a click that worked. With `--ve
|
|
|
79
92
|
"change": {"changed": true, "magnitude": 0.18, "threshold": 0.002, "bbox": [40,120,600,400]}
|
|
80
93
|
```
|
|
81
94
|
|
|
95
|
+
**`--verify` also gives you the screenshot taken after the action**, at the `screenshot` path in
|
|
96
|
+
the same result. It captured that screen to do the comparison, so you already paid for it: do not
|
|
97
|
+
follow a verified action with a `screenshot` call. That is the round trip verify exists to save.
|
|
98
|
+
|
|
82
99
|
- `changed: false` after a click → the coordinate was probably stale. **Ask ui-locator again.
|
|
83
100
|
Do not click the same pixel twice.**
|
|
84
101
|
- `changed: true` with a tiny `magnitude` in a corner → a clock or a caret, not a response.
|
|
@@ -44,6 +44,10 @@ def isolated_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[No
|
|
|
44
44
|
(home / ".local" / "share").mkdir(parents=True, exist_ok=True)
|
|
45
45
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
|
46
46
|
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
|
47
|
+
# Both mechanisms, because they are genuinely different: Path.home() consults HOME through
|
|
48
|
+
# os.path.expanduser, but a patched Path.home does not reach expanduser() at all -- and a
|
|
49
|
+
# path like "~/shots" resolves through the latter.
|
|
50
|
+
monkeypatch.setenv("HOME", str(home))
|
|
47
51
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
|
48
52
|
yield
|
|
49
53
|
|
|
@@ -60,6 +64,23 @@ def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
60
64
|
return root
|
|
61
65
|
|
|
62
66
|
|
|
67
|
+
class CliResult(Protocol):
|
|
68
|
+
"""What a CliRunner result gives us.
|
|
69
|
+
|
|
70
|
+
typer.testing re-exports click's Result without declaring it, so importing the name fails
|
|
71
|
+
under mypy strict on the supported typer floor -- and importing it from click directly would
|
|
72
|
+
mean depending on somebody else's transitive dependency. Structural typing needs neither.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
exit_code: int
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def stdout(self) -> str: ...
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def stderr(self) -> str: ...
|
|
82
|
+
|
|
83
|
+
|
|
63
84
|
class WriteConfig(Protocol):
|
|
64
85
|
"""Writes a config.toml into the project fixture's root."""
|
|
65
86
|
|
|
@@ -8,7 +8,7 @@ from importlib import metadata
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
11
|
-
from typer.testing import CliRunner
|
|
11
|
+
from typer.testing import CliRunner
|
|
12
12
|
|
|
13
13
|
if sys.version_info >= (3, 11):
|
|
14
14
|
import tomllib
|
|
@@ -16,7 +16,7 @@ else:
|
|
|
16
16
|
import tomli as tomllib
|
|
17
17
|
|
|
18
18
|
import use_computer
|
|
19
|
-
from tests.conftest import WriteConfig, strip_ansi
|
|
19
|
+
from tests.conftest import CliResult, WriteConfig, strip_ansi
|
|
20
20
|
from tests.fake_backend import FakeBackend
|
|
21
21
|
from use_computer import cli
|
|
22
22
|
from use_computer.cli import EXIT_FAILURE, EXIT_OK, EXIT_USAGE, app, apply_default_command
|
|
@@ -43,7 +43,7 @@ def runner() -> CliRunner:
|
|
|
43
43
|
return CliRunner()
|
|
44
44
|
|
|
45
45
|
|
|
46
|
-
def invoke(runner: CliRunner, *args: str) ->
|
|
46
|
+
def invoke(runner: CliRunner, *args: str) -> CliResult:
|
|
47
47
|
return runner.invoke(app, list(args), catch_exceptions=False)
|
|
48
48
|
|
|
49
49
|
|
|
@@ -7,7 +7,13 @@ from pathlib import Path
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from tests.conftest import WriteConfig
|
|
10
|
-
from use_computer.config import
|
|
10
|
+
from use_computer.config import (
|
|
11
|
+
PROJECT_DIR,
|
|
12
|
+
default_screenshot_dir,
|
|
13
|
+
find_project_root,
|
|
14
|
+
load,
|
|
15
|
+
xdg_config_dir,
|
|
16
|
+
)
|
|
11
17
|
from use_computer.errors import ConfigError
|
|
12
18
|
|
|
13
19
|
CONFIG = """
|
|
@@ -165,3 +171,29 @@ def test_a_profile_field_default_is_reported_like_any_other(write_config: WriteC
|
|
|
165
171
|
"env": "USE_COMPUTER_PORT",
|
|
166
172
|
"source": payload["config-file"],
|
|
167
173
|
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_the_screenshot_directory_defaults_to_the_xdg_data_dir(
|
|
177
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
178
|
+
) -> None:
|
|
179
|
+
# Never a cache directory: a screenshot of somebody's desktop is not disposable.
|
|
180
|
+
monkeypatch.setenv("XDG_DATA_HOME", "/somewhere/data")
|
|
181
|
+
assert default_screenshot_dir() == Path("/somewhere/data/use-computer/screenshots")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_the_screenshot_directory_is_configurable_like_anything_else(
|
|
185
|
+
write_config: WriteConfig,
|
|
186
|
+
) -> None:
|
|
187
|
+
# Prepended, not appended: a key after a [table] header belongs to that table.
|
|
188
|
+
write_config('screenshot-dir = "/shots"\n' + CONFIG)
|
|
189
|
+
resolved = load(profile="laptop", environ={})
|
|
190
|
+
assert resolved.settings.screenshot_dir == Path("/shots")
|
|
191
|
+
assert resolved.values["screenshot_dir"].layer == "config"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_a_screenshot_directory_from_the_environment_is_a_path_not_a_string(
|
|
195
|
+
write_config: WriteConfig,
|
|
196
|
+
) -> None:
|
|
197
|
+
write_config(CONFIG)
|
|
198
|
+
resolved = load(profile="laptop", environ={"USE_COMPUTER_SCREENSHOT_DIR": "~/shots"})
|
|
199
|
+
assert resolved.settings.screenshot_dir == Path.home() / "shots"
|
|
@@ -7,9 +7,9 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
|
-
from typer.testing import CliRunner
|
|
10
|
+
from typer.testing import CliRunner
|
|
11
11
|
|
|
12
|
-
from tests.conftest import strip_ansi
|
|
12
|
+
from tests.conftest import CliResult, strip_ansi
|
|
13
13
|
from tests.fake_backend import FakeBackend
|
|
14
14
|
from use_computer.cli import EXIT_FAILURE, EXIT_OK, EXIT_USAGE, app
|
|
15
15
|
from use_computer.config import (
|
|
@@ -24,7 +24,7 @@ from use_computer.config import (
|
|
|
24
24
|
from use_computer.errors import ConfigError
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
def invoke(*args: str, stdin_is_a_tty: bool = False) ->
|
|
27
|
+
def invoke(*args: str, stdin_is_a_tty: bool = False) -> CliResult:
|
|
28
28
|
return CliRunner().invoke(app, list(args), catch_exceptions=False)
|
|
29
29
|
|
|
30
30
|
|
|
@@ -148,14 +148,29 @@ def test_verification_is_off_unless_asked_for() -> None:
|
|
|
148
148
|
assert result.results[0].change is None
|
|
149
149
|
|
|
150
150
|
|
|
151
|
-
def
|
|
151
|
+
def test_a_screenshot_action_returns_a_path_and_never_bytes(tmp_path: Path) -> None:
|
|
152
152
|
backend = FakeBackend()
|
|
153
|
-
result = session(backend).run([ScreenshotAction(
|
|
153
|
+
result = session(backend, screenshot_dir=tmp_path / 'shots').run([ScreenshotAction()])
|
|
154
154
|
payload = as_json(result)
|
|
155
155
|
shot = payload["results"][0]["screenshot"]
|
|
156
156
|
assert shot is not None
|
|
157
157
|
assert shot["width"] == 2560
|
|
158
|
-
assert shot["
|
|
158
|
+
assert Path(shot["path"]).is_file()
|
|
159
|
+
# A megabyte of base64 in the agent's context is the most expensive place it could go.
|
|
160
|
+
assert "base64" not in shot
|
|
161
|
+
assert "data" not in shot
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_a_screenshot_without_a_path_still_lands_in_the_screenshot_directory(
|
|
165
|
+
tmp_path: Path,
|
|
166
|
+
) -> None:
|
|
167
|
+
backend = FakeBackend()
|
|
168
|
+
result = session(backend, screenshot_dir=tmp_path / 'shots').run([ScreenshotAction()])
|
|
169
|
+
written = list((tmp_path / 'shots').iterdir())
|
|
170
|
+
assert len(written) == 1
|
|
171
|
+
assert written[0].name.endswith("-screenshot.png")
|
|
172
|
+
assert result.results[0].screenshot is not None
|
|
173
|
+
assert result.results[0].screenshot.path == written[0]
|
|
159
174
|
|
|
160
175
|
|
|
161
176
|
def test_a_screenshot_action_writes_the_file_it_was_given(tmp_path: Path) -> None:
|
|
@@ -164,6 +179,32 @@ def test_a_screenshot_action_writes_the_file_it_was_given(tmp_path: Path) -> Non
|
|
|
164
179
|
result = session(backend).run([ScreenshotAction(out=out)])
|
|
165
180
|
assert out.is_file()
|
|
166
181
|
assert result.results[0].screenshot is not None
|
|
182
|
+
assert result.results[0].screenshot.path == out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_verify_writes_the_screen_it_already_captured_and_reports_where(
|
|
186
|
+
tmp_path: Path,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""The capture is paid for either way; the path is what saves the agent a round trip."""
|
|
189
|
+
backend = FakeBackend(colours=[(0, 0, 0), (255, 255, 255)])
|
|
190
|
+
result = session(backend, verify=True, screenshot_dir=tmp_path / 'shots').run(
|
|
191
|
+
[ClickAction(x=10, y=10)]
|
|
192
|
+
)
|
|
193
|
+
shot = result.results[0].screenshot
|
|
194
|
+
assert shot is not None
|
|
195
|
+
assert shot.path is not None
|
|
196
|
+
assert shot.path.is_file()
|
|
197
|
+
assert shot.path.name.endswith("-click.png")
|
|
198
|
+
assert result.results[0].change is not None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_a_verified_batch_writes_one_file_per_action_not_two(tmp_path: Path) -> None:
|
|
202
|
+
# The after-screenshot of one action is reused as the before of the next.
|
|
203
|
+
backend = FakeBackend()
|
|
204
|
+
session(backend, verify=True, screenshot_dir=tmp_path / 'shots').run(
|
|
205
|
+
[ClickAction(x=1, y=1), ClickAction(x=2, y=2), ClickAction(x=3, y=3)]
|
|
206
|
+
)
|
|
207
|
+
assert len(list((tmp_path / 'shots').iterdir())) == 3
|
|
167
208
|
|
|
168
209
|
|
|
169
210
|
def test_the_backend_is_closed_even_when_an_action_failed() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|