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.
Files changed (32) hide show
  1. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/PKG-INFO +5 -2
  2. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/README.md +4 -1
  3. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/pyproject.toml +1 -1
  4. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/actions.py +3 -2
  5. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/cli.py +6 -4
  6. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/compare.py +18 -9
  7. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/config.py +16 -0
  8. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/runner.py +26 -32
  9. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/skill/SKILL.md +19 -2
  10. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/conftest.py +21 -0
  11. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_cli.py +3 -3
  12. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_config.py +33 -1
  13. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_config_init.py +3 -3
  14. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_runner.py +44 -3
  15. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/.gitignore +0 -0
  16. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/LICENSE +0 -0
  17. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/__init__.py +0 -0
  18. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/__init__.py +0 -0
  19. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/base.py +0 -0
  20. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/local.py +0 -0
  21. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/backends/vnc.py +0 -0
  22. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/coordinates.py +0 -0
  23. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/errors.py +0 -0
  24. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/keys.py +0 -0
  25. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/src/use_computer/skill/__init__.py +0 -0
  26. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/__init__.py +0 -0
  27. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/fake_backend.py +0 -0
  28. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_backends.py +0 -0
  29. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_compare.py +0 -0
  30. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_coordinates.py +0 -0
  31. {use_computer_cli-0.1.0 → use_computer_cli-0.1.1}/tests/test_keys.py +0 -0
  32. {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.0
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 --out shot.png
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 --out shot.png
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.0"
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 = None
142
- base64: bool = False
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[Path | None, typer.Option("--out", help="Write the PNG here.")] = None,
327
- base64: Annotated[bool, typer.Option("--base64", help="Include the PNG in the JSON.")] = False,
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, base64=base64)], config, verbose)
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 base64(self) -> str:
54
- """The PNG bytes, base64-encoded, for transport to the calling agent."""
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
- return base64.b64encode(self.data).decode("ascii")
57
- if self.path is not None:
58
- return base64.b64encode(self.path.read_bytes()).decode("ascii")
59
- raise ValueError("screenshot has neither data nor path")
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 BackendProfile, ResolvedConfig, Settings
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
- is_capture = isinstance(action, ScreenshotAction)
203
- screenshot = (screenshot or after) if is_capture else after
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
- if action.out is not None:
252
- action.out.parent.mkdir(parents=True, exist_ok=True)
253
- if shot.data is not None:
254
- action.out.write_bytes(shot.data)
255
- shot = shot.model_copy(update={"path": action.out})
256
- if not action.base64:
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, with screenshot bytes rendered as base64 where they were requested."""
284
- payload = result.model_dump(mode="json", exclude={"results": {"__all__": {"screenshot"}}})
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 --out shot.png --use staging
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
- ## Verify how you know it worked
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, Result
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) -> Result:
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 PROJECT_DIR, find_project_root, load, xdg_config_dir
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, Result
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) -> Result:
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 test_a_screenshot_action_returns_the_capture() -> None:
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(base64=True)])
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["base64"]
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: