Tests: watchdog, audit and the AT-SPI guard
Browse files- tests/conftest.py +16 -0
- tests/test_accessibility.py +12 -4
- tests/test_api_surface.py +1 -1
- tests/test_atspi_guard.py +46 -0
- tests/test_audit.py +146 -0
- tests/test_cli.py +59 -0
- tests/test_gates.py +38 -1
- tests/test_watchdog.py +158 -0
tests/conftest.py
CHANGED
|
@@ -20,3 +20,19 @@ def require_ocr() -> None:
|
|
| 20 |
available, detail = detector_available("ocr")
|
| 21 |
if not available:
|
| 22 |
pytest.skip(f"no OCR engine installed: {detail}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
available, detail = detector_available("ocr")
|
| 21 |
if not available:
|
| 22 |
pytest.skip(f"no OCR engine installed: {detail}")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def require_atspi() -> None:
|
| 26 |
+
"""Skip the calling test unless the AT-SPI reader can actually be built.
|
| 27 |
+
|
| 28 |
+
The same trap as `require_ocr`, one layer down. `make_detector("atspi")` is
|
| 29 |
+
right to raise when PyGObject is missing - but a test that calls it on a
|
| 30 |
+
machine without the typelib *fails* where it should skip, and the failure
|
| 31 |
+
reads like a harness defect. Ask the backend that needs the typelib, never a
|
| 32 |
+
name that resolves anyway.
|
| 33 |
+
"""
|
| 34 |
+
from tiny_computer_agent.accessibility import accessibility_available
|
| 35 |
+
|
| 36 |
+
available, detail = accessibility_available("atspi")
|
| 37 |
+
if not available:
|
| 38 |
+
pytest.skip(f"no AT-SPI reader on this machine: {detail}")
|
tests/test_accessibility.py
CHANGED
|
@@ -8,6 +8,8 @@ one test that drives actual AT-SPI is opt-in, like the live-desktop tests.
|
|
| 8 |
import os
|
| 9 |
|
| 10 |
import pytest
|
|
|
|
|
|
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
import platform
|
|
@@ -241,11 +243,12 @@ def test_the_auto_detector_still_defaults_to_ocr_then_grid():
|
|
| 241 |
|
| 242 |
|
| 243 |
def test_the_new_detector_names_are_reachable():
|
| 244 |
-
assert type(make_detector("atspi")).__name__ == "AtspiDetector"
|
| 245 |
assert type(make_detector("best")).__name__ == "AutoDetector"
|
|
|
|
|
|
|
|
|
|
| 246 |
ok, detail = detector_available("atspi")
|
| 247 |
assert isinstance(ok, bool) and detail
|
| 248 |
-
assert TEXT_ROLES and "label" in TEXT_ROLES
|
| 249 |
|
| 250 |
|
| 251 |
# -- the Windows backend: its mapping, exercised without Windows ---------------
|
|
@@ -331,12 +334,17 @@ def test_the_uia_reader_refuses_rather_than_returning_an_empty_index():
|
|
| 331 |
UiaReader().read()
|
| 332 |
|
| 333 |
|
| 334 |
-
def
|
| 335 |
-
|
| 336 |
ok, detail = detector_available("uia")
|
| 337 |
assert isinstance(ok, bool) and detail
|
| 338 |
|
| 339 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
def test_a_provider_that_throws_is_reported_as_unavailable():
|
| 341 |
def broken():
|
| 342 |
raise RuntimeError("COM is not initialised")
|
|
|
|
| 8 |
import os
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
+
|
| 12 |
+
from conftest import require_atspi
|
| 13 |
from PIL import Image
|
| 14 |
|
| 15 |
import platform
|
|
|
|
| 243 |
|
| 244 |
|
| 245 |
def test_the_new_detector_names_are_reachable():
|
|
|
|
| 246 |
assert type(make_detector("best")).__name__ == "AutoDetector"
|
| 247 |
+
assert TEXT_ROLES and "label" in TEXT_ROLES
|
| 248 |
+
require_atspi() # the atspi name needs the typelib to build
|
| 249 |
+
assert type(make_detector("atspi")).__name__ == "AtspiDetector"
|
| 250 |
ok, detail = detector_available("atspi")
|
| 251 |
assert isinstance(ok, bool) and detail
|
|
|
|
| 252 |
|
| 253 |
|
| 254 |
# -- the Windows backend: its mapping, exercised without Windows ---------------
|
|
|
|
| 334 |
UiaReader().read()
|
| 335 |
|
| 336 |
|
| 337 |
+
def test_the_windows_detector_name_is_reachable_without_windows():
|
| 338 |
+
"""The Windows backend's *mapping* is exercised anywhere; its tree is not."""
|
| 339 |
ok, detail = detector_available("uia")
|
| 340 |
assert isinstance(ok, bool) and detail
|
| 341 |
|
| 342 |
|
| 343 |
+
def test_the_detector_names_cover_both_backends():
|
| 344 |
+
require_atspi()
|
| 345 |
+
assert type(make_detector("atspi")).__name__ == "AtspiDetector"
|
| 346 |
+
|
| 347 |
+
|
| 348 |
def test_a_provider_that_throws_is_reported_as_unavailable():
|
| 349 |
def broken():
|
| 350 |
raise RuntimeError("COM is not initialised")
|
tests/test_api_surface.py
CHANGED
|
@@ -21,7 +21,7 @@ def test_no_public_callable_in_a_module_is_missing_from_all():
|
|
| 21 |
modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
|
| 22 |
"safety", "desktop", "demo", "trace",
|
| 23 |
# 2.0: the operator layer, all of which must stay importable from the root
|
| 24 |
-
"diff", "gates", "runner", "skills"]
|
| 25 |
unexported = []
|
| 26 |
for name in modules:
|
| 27 |
module = importlib.import_module(f"tiny_computer_agent.{name}")
|
|
|
|
| 21 |
modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
|
| 22 |
"safety", "desktop", "demo", "trace",
|
| 23 |
# 2.0: the operator layer, all of which must stay importable from the root
|
| 24 |
+
"diff", "gates", "runner", "skills", "watchdog", "audit"]
|
| 25 |
unexported = []
|
| 26 |
for name in modules:
|
| 27 |
module = importlib.import_module(f"tiny_computer_agent.{name}")
|
tests/test_atspi_guard.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The AT-SPI guard must actually skip when there is no typelib.
|
| 2 |
+
|
| 3 |
+
Same bug, one layer down from the OCR guard: `make_detector("atspi")` correctly
|
| 4 |
+
raises without PyGObject, so two tests failed on a machine that has no AT-SPI
|
| 5 |
+
reader - and a failing test reads like a harness defect rather than a missing
|
| 6 |
+
system package. The guard now asks the backend that needs the typelib, and here
|
| 7 |
+
is the proof that it skips, passes, and asks for the right thing.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from conftest import require_atspi
|
| 13 |
+
|
| 14 |
+
import tiny_computer_agent.accessibility as accessibility
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_the_guard_skips_when_the_typelib_is_missing(monkeypatch):
|
| 18 |
+
monkeypatch.setattr(accessibility, "accessibility_available",
|
| 19 |
+
lambda name="atspi": (False, "simulated: no PyGObject"))
|
| 20 |
+
with pytest.raises(pytest.skip.Exception):
|
| 21 |
+
require_atspi()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_the_guard_passes_when_the_reader_can_be_built(monkeypatch):
|
| 25 |
+
monkeypatch.setattr(accessibility, "accessibility_available",
|
| 26 |
+
lambda name="atspi": (True, "simulated: AT-SPI reader ready"))
|
| 27 |
+
require_atspi() # must not raise
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_the_guard_asks_for_atspi_and_not_a_name_that_always_resolves(monkeypatch):
|
| 31 |
+
asked = []
|
| 32 |
+
|
| 33 |
+
def record(name="atspi"):
|
| 34 |
+
asked.append(name)
|
| 35 |
+
return True, "recorded"
|
| 36 |
+
|
| 37 |
+
monkeypatch.setattr(accessibility, "accessibility_available", record)
|
| 38 |
+
require_atspi()
|
| 39 |
+
assert asked == ["atspi"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_uia_is_reported_unavailable_on_linux_and_that_is_not_a_failure():
|
| 43 |
+
"""Why the Windows test had to be split rather than guarded as a whole."""
|
| 44 |
+
available, detail = accessibility.accessibility_available("uia")
|
| 45 |
+
assert available is False, "UI Automation is Windows-only"
|
| 46 |
+
assert detail, "an unavailable backend still has to say why"
|
tests/test_audit.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The record must be checkable, and must say when it is not.
|
| 2 |
+
|
| 3 |
+
A run's JSON can be edited by whoever would prefer a better result. These tests write a real
|
| 4 |
+
log from a real episode, then do the three things a dishonest reader would try - edit a line,
|
| 5 |
+
delete a line, reorder them - and require the verifier to name the entry at fault.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
from tiny_computer_agent import (
|
| 11 |
+
AgentLoop, AuditLog, Element, FakeScreen, MockBrain, observation_digest, verify_audit,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _screen():
|
| 16 |
+
return FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _brain():
|
| 20 |
+
return MockBrain([{"kind": "click", "label": "Save"},
|
| 21 |
+
{"kind": "done", "reason": "file saved"}])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _run(tmp_path, name="audit.jsonl", **kwargs):
|
| 25 |
+
log = AuditLog(tmp_path / name)
|
| 26 |
+
loop = AgentLoop(screen=_screen(), brain=_brain(), audit=log, **kwargs)
|
| 27 |
+
return loop.run("click 'Save' to save the document"), log
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# -- writing ------------------------------------------------------------------- #
|
| 31 |
+
|
| 32 |
+
def test_a_run_writes_one_chained_entry_per_step_plus_a_closing_one(tmp_path):
|
| 33 |
+
episode, log = _run(tmp_path)
|
| 34 |
+
entries = log.entries()
|
| 35 |
+
assert [e.kind for e in entries] == ["step", "step", "episode"]
|
| 36 |
+
assert [e.index for e in entries] == [0, 1, 2]
|
| 37 |
+
assert entries[0].prev == "", "the first entry follows nothing"
|
| 38 |
+
assert entries[1].prev == entries[0].digest
|
| 39 |
+
assert all(e.digest and len(e.digest) == 64 for e in entries)
|
| 40 |
+
assert "click label='Save'" in entries[0].action
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_the_chain_verifies_after_a_real_run(tmp_path):
|
| 44 |
+
_run(tmp_path)
|
| 45 |
+
ok, detail = verify_audit(tmp_path / "audit.jsonl")
|
| 46 |
+
assert ok, detail
|
| 47 |
+
assert detail == "3 entries, chain intact"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_the_closing_entry_carries_the_verdict_not_the_brains_word(tmp_path):
|
| 51 |
+
episode, log = _run(tmp_path)
|
| 52 |
+
assert episode.success
|
| 53 |
+
closing = log.entries()[-1]
|
| 54 |
+
assert closing.kind == "episode"
|
| 55 |
+
assert closing.ok is True
|
| 56 |
+
assert "success" in closing.outcome and episode.stop_reason in closing.outcome
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_render_counts_the_steps_and_the_chain(tmp_path):
|
| 60 |
+
_, log = _run(tmp_path)
|
| 61 |
+
line = log.render()
|
| 62 |
+
assert "2 steps" in line
|
| 63 |
+
assert "chain intact" in line
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_a_run_with_no_audit_writes_nothing(tmp_path):
|
| 67 |
+
episode = AgentLoop(screen=_screen(), brain=_brain()).run("save")
|
| 68 |
+
assert episode.success
|
| 69 |
+
assert list(tmp_path.iterdir()) == []
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_resuming_a_log_continues_its_chain(tmp_path):
|
| 73 |
+
"""A runner that is killed and restarted must not start a second, unrelated log."""
|
| 74 |
+
_run(tmp_path)
|
| 75 |
+
episode, log = _run(tmp_path) # same path, fresh AuditLog
|
| 76 |
+
assert episode.success
|
| 77 |
+
entries = log.entries()
|
| 78 |
+
assert [e.index for e in entries] == [0, 1, 2, 3, 4, 5]
|
| 79 |
+
assert entries[3].prev == entries[2].digest
|
| 80 |
+
assert verify_audit(tmp_path / "audit.jsonl")[0] is True
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# -- tampering ----------------------------------------------------------------- #
|
| 84 |
+
|
| 85 |
+
def _rewrite(tmp_path, lines, name="audit.jsonl"):
|
| 86 |
+
(tmp_path / name).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_an_edited_entry_is_caught_and_named(tmp_path):
|
| 90 |
+
_run(tmp_path)
|
| 91 |
+
path = tmp_path / "audit.jsonl"
|
| 92 |
+
lines = path.read_text(encoding="utf-8").splitlines()
|
| 93 |
+
entry = json.loads(lines[1])
|
| 94 |
+
entry["outcome"] = "clicked without incident" # a nicer story
|
| 95 |
+
lines[1] = json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
| 96 |
+
_rewrite(tmp_path, lines)
|
| 97 |
+
|
| 98 |
+
ok, detail = verify_audit(path)
|
| 99 |
+
assert ok is False
|
| 100 |
+
assert "entry 2" in detail and "was edited" in detail
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_a_deleted_line_is_caught(tmp_path):
|
| 104 |
+
_run(tmp_path)
|
| 105 |
+
path = tmp_path / "audit.jsonl"
|
| 106 |
+
lines = path.read_text(encoding="utf-8").splitlines()
|
| 107 |
+
_rewrite(tmp_path, [lines[0], lines[2]])
|
| 108 |
+
ok, detail = verify_audit(path)
|
| 109 |
+
assert ok is False
|
| 110 |
+
assert "entry 2" in detail and "out of order" in detail
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_reordered_lines_are_caught(tmp_path):
|
| 114 |
+
_run(tmp_path)
|
| 115 |
+
path = tmp_path / "audit.jsonl"
|
| 116 |
+
lines = path.read_text(encoding="utf-8").splitlines()
|
| 117 |
+
_rewrite(tmp_path, [lines[1], lines[0], lines[2]])
|
| 118 |
+
ok, detail = verify_audit(path)
|
| 119 |
+
assert ok is False
|
| 120 |
+
assert "entry 1" in detail
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_a_missing_log_is_not_reported_as_fine(tmp_path):
|
| 124 |
+
ok, detail = verify_audit(tmp_path / "nothing-here.jsonl")
|
| 125 |
+
assert ok is False and "no audit log" in detail
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_an_empty_log_is_not_reported_as_fine(tmp_path):
|
| 129 |
+
path = tmp_path / "empty.jsonl"
|
| 130 |
+
path.write_text("")
|
| 131 |
+
ok, detail = verify_audit(path)
|
| 132 |
+
assert ok is False and "empty" in detail
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# -- the screen digest --------------------------------------------------------- #
|
| 136 |
+
|
| 137 |
+
def test_the_digest_identifies_the_index_not_the_pixels(tmp_path):
|
| 138 |
+
same = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]).capture()
|
| 139 |
+
again = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]).capture()
|
| 140 |
+
moved = FakeScreen([(Element("Save", x=400, y=120, width=80, height=24),)]).capture()
|
| 141 |
+
renamed = FakeScreen([(Element("Cancel", x=320, y=120, width=80, height=24),)]).capture()
|
| 142 |
+
|
| 143 |
+
assert observation_digest(same) == observation_digest(again)
|
| 144 |
+
assert observation_digest(same) != observation_digest(moved)
|
| 145 |
+
assert observation_digest(same) != observation_digest(renamed)
|
| 146 |
+
assert len(observation_digest(same)) == 16
|
tests/test_cli.py
CHANGED
|
@@ -3,6 +3,7 @@ import json
|
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from tiny_computer_agent import __main__ as cli
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
def test_demo_runs_dry_run_by_default(capsys):
|
|
@@ -153,3 +154,61 @@ def test_batch_rejects_tasks_that_are_not_tasks(tmp_path, capsys):
|
|
| 153 |
missing = tmp_path / "absent.json"
|
| 154 |
assert cli.main(["batch", str(missing)]) == 2
|
| 155 |
assert "cannot read" in capsys.readouterr().err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from tiny_computer_agent import __main__ as cli
|
| 6 |
+
from tiny_computer_agent import verify_audit
|
| 7 |
|
| 8 |
|
| 9 |
def test_demo_runs_dry_run_by_default(capsys):
|
|
|
|
| 154 |
missing = tmp_path / "absent.json"
|
| 155 |
assert cli.main(["batch", str(missing)]) == 2
|
| 156 |
assert "cannot read" in capsys.readouterr().err
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# -- 2.0: the operator's gate, and a record of the run that can be checked ------- #
|
| 160 |
+
|
| 161 |
+
SCRIPT = '[{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "saved"}]'
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_the_audit_flag_writes_a_chain_that_verifies(tmp_path, capsys):
|
| 165 |
+
path = tmp_path / "run.jsonl"
|
| 166 |
+
assert cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)]) == 0
|
| 167 |
+
out = capsys.readouterr().out
|
| 168 |
+
assert "recording this run to" in out
|
| 169 |
+
assert "audit:" in out and "chain intact" in out
|
| 170 |
+
ok, detail = verify_audit(path)
|
| 171 |
+
assert ok, detail
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_verify_audit_accepts_an_intact_chain(tmp_path, capsys):
|
| 175 |
+
path = tmp_path / "run.jsonl"
|
| 176 |
+
cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)])
|
| 177 |
+
assert cli.main(["--verify-audit", str(path)]) == 0
|
| 178 |
+
assert "ok" in capsys.readouterr().out
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_verify_audit_fails_loudly_on_a_tampered_chain(tmp_path, capsys):
|
| 182 |
+
path = tmp_path / "run.jsonl"
|
| 183 |
+
cli.main(["--image", "example", "--script", SCRIPT, "--audit", str(path)])
|
| 184 |
+
lines = path.read_text(encoding="utf-8").splitlines()
|
| 185 |
+
entry = json.loads(lines[0])
|
| 186 |
+
entry["outcome"] = "nothing happened" # a nicer story
|
| 187 |
+
lines[0] = json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
| 188 |
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 189 |
+
|
| 190 |
+
assert cli.main(["--verify-audit", str(path)]) == 1
|
| 191 |
+
out = capsys.readouterr().out
|
| 192 |
+
assert "FAIL" in out and "entry 1" in out
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_a_bad_gate_spec_is_a_usage_error(capsys):
|
| 196 |
+
assert cli.main(["--gate", "yes-please"]) == 2
|
| 197 |
+
assert "bad --gate" in capsys.readouterr().err
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_the_read_only_gate_refuses_to_change_anything(capsys):
|
| 201 |
+
"""The gate is the operator's yes; read-only means the answer is no to any change."""
|
| 202 |
+
code = cli.main(["--image", "example", "--script",
|
| 203 |
+
'[{"kind": "click", "label": "Save"}]', "--gate", "read-only"])
|
| 204 |
+
out = capsys.readouterr().out
|
| 205 |
+
assert code == 1, "a refused action is a failed episode, not a success"
|
| 206 |
+
assert "gate: read-only" in out
|
| 207 |
+
assert "operator refused" in out and "read-only gate" in out
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_the_labels_gate_lets_a_whitelisted_click_through(capsys):
|
| 211 |
+
code = cli.main(["--image", "example", "--script", SCRIPT, "--gate", "labels:Save"])
|
| 212 |
+
out = capsys.readouterr().out
|
| 213 |
+
assert code == 0, out
|
| 214 |
+
assert "is whitelisted" in out or "would click" in out
|
tests/test_gates.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
|
|
|
|
|
| 1 |
"""A refusal from the operator is a step, not a traceback."""
|
| 2 |
|
| 3 |
from tiny_computer_agent import (
|
| 4 |
Action, AgentLoop, AllOf, AllowAll, AllowOnlyLabels, AnyOf, Approval, DenyAll, Element,
|
| 5 |
-
FakeScreen, InteractiveGate, MockBrain, Policy, mutating,
|
| 6 |
)
|
| 7 |
|
| 8 |
|
|
@@ -95,3 +97,38 @@ def test_a_gate_refusal_stops_the_episode_and_is_recorded():
|
|
| 95 |
assert episode.steps[0].error is not None and episode.steps[0].error.startswith("not approved")
|
| 96 |
assert episode.steps[0].result is None, "nothing was executed"
|
| 97 |
assert len(episode.steps) == 1, "the episode ended there"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
"""A refusal from the operator is a step, not a traceback."""
|
| 4 |
|
| 5 |
from tiny_computer_agent import (
|
| 6 |
Action, AgentLoop, AllOf, AllowAll, AllowOnlyLabels, AnyOf, Approval, DenyAll, Element,
|
| 7 |
+
FakeScreen, InteractiveGate, MockBrain, Policy, gate_from_spec, mutating,
|
| 8 |
)
|
| 9 |
|
| 10 |
|
|
|
|
| 97 |
assert episode.steps[0].error is not None and episode.steps[0].error.startswith("not approved")
|
| 98 |
assert episode.steps[0].result is None, "nothing was executed"
|
| 99 |
assert len(episode.steps) == 1, "the episode ended there"
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# -- the one-line spelling the command line takes -------------------------------- #
|
| 103 |
+
|
| 104 |
+
def test_gate_from_spec_read_only_allows_a_read_and_refuses_a_change():
|
| 105 |
+
gate = gate_from_spec("read-only")
|
| 106 |
+
assert gate.approve(Action("wait", seconds=1)).approved
|
| 107 |
+
refusal = gate.approve(Action("click", label="Save"))
|
| 108 |
+
assert refusal.approved is False
|
| 109 |
+
assert "read-only gate" in refusal.reason
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_gate_from_spec_labels_normalise_what_the_operator_wrote():
|
| 113 |
+
gate = gate_from_spec("labels: save , Cancel")
|
| 114 |
+
assert gate.approve(Action("click", label="Save")).approved
|
| 115 |
+
assert gate.approve(Action("key", keys="ctrl+s")).approved is False
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_gate_from_spec_interactive_asks_the_operator():
|
| 119 |
+
assert type(gate_from_spec("interactive")).__name__ == "InteractiveGate"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_gate_from_spec_allow_all_approves_whatever_policy_allowed():
|
| 123 |
+
assert gate_from_spec("allow-all").approve(Action("click", label="Save")).approved
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_an_unknown_spec_says_what_it_accepts():
|
| 127 |
+
"""A typo in a safety switch must never quietly mean "allow everything"."""
|
| 128 |
+
with pytest.raises(ValueError, match="allow-all"):
|
| 129 |
+
gate_from_spec("yes-please")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_labels_with_no_labels_is_a_configuration_error():
|
| 133 |
+
with pytest.raises(ValueError, match="at least one label"):
|
| 134 |
+
gate_from_spec("labels: , ")
|
tests/test_watchdog.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A call that never returns must not be a harness that never returns.
|
| 2 |
+
|
| 3 |
+
The loop stops a *repeated* action and a step budget stops a long episode, but nothing
|
| 4 |
+
stopped a single call that hangs: no step, no reason, no evidence, and a console that looks
|
| 5 |
+
like it is thinking. These tests pin the watchdog's two promises - the loop stops waiting and
|
| 6 |
+
says which call it stopped waiting for - and its one honest limit, that a Python thread
|
| 7 |
+
cannot be killed, only abandoned.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from tiny_computer_agent import (
|
| 15 |
+
AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Watchdog, WatchdogTimeout,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# -- the unit: one guarded call ------------------------------------------------ #
|
| 20 |
+
|
| 21 |
+
def test_a_call_that_returns_passes_its_value_through():
|
| 22 |
+
watchdog = Watchdog(step_timeout=5)
|
| 23 |
+
assert watchdog.call("decide", lambda a, b: a + b, 2, 3) == 5
|
| 24 |
+
assert watchdog.orphans == []
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_a_call_that_overruns_is_abandoned_and_named():
|
| 28 |
+
watchdog = Watchdog(step_timeout=0.05)
|
| 29 |
+
started = time.monotonic()
|
| 30 |
+
with pytest.raises(WatchdogTimeout) as raised:
|
| 31 |
+
watchdog.call("execute", lambda: time.sleep(3))
|
| 32 |
+
elapsed = time.monotonic() - started
|
| 33 |
+
|
| 34 |
+
assert elapsed < 1.5, "the watchdog waited for the deadline, not for the call"
|
| 35 |
+
assert raised.value.where == "execute"
|
| 36 |
+
assert raised.value.seconds == 0.05
|
| 37 |
+
assert "execute did not finish within 0.05s" in str(raised.value)
|
| 38 |
+
assert watchdog.orphans == ["execute"], "an abandoned call is recorded, not forgiven"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_the_wall_clock_refuses_the_next_call_before_it_starts():
|
| 42 |
+
"""A spent wall clock is reported as un-attempted, never as a timeout of what was next."""
|
| 43 |
+
watchdog = Watchdog(step_timeout=5, wall_clock=0).start()
|
| 44 |
+
called = []
|
| 45 |
+
with pytest.raises(WatchdogTimeout) as raised:
|
| 46 |
+
watchdog.call("decide", lambda: called.append("ran"))
|
| 47 |
+
assert called == [], "nothing may be attempted once the wall clock is spent"
|
| 48 |
+
assert raised.value.abandoned is False
|
| 49 |
+
assert "not attempted" in str(raised.value)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_no_per_call_deadline_runs_the_call_inline():
|
| 53 |
+
"""The honest default for a local desktop: this process *is* the thing being watched."""
|
| 54 |
+
watchdog = Watchdog(step_timeout=None)
|
| 55 |
+
assert watchdog.call("execute", lambda: "inline") == "inline"
|
| 56 |
+
assert watchdog.threads_started == 0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_an_exception_inside_a_guarded_call_reaches_the_caller_unchanged():
|
| 60 |
+
def broken():
|
| 61 |
+
raise ValueError("the executor is broken, not slow")
|
| 62 |
+
|
| 63 |
+
with pytest.raises(ValueError, match="not slow"):
|
| 64 |
+
Watchdog(step_timeout=5).call("execute", broken)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_render_names_the_limits_and_every_abandoned_call():
|
| 68 |
+
watchdog = Watchdog(step_timeout=0.05, wall_clock=60).start()
|
| 69 |
+
with pytest.raises(WatchdogTimeout):
|
| 70 |
+
watchdog.call("decide", lambda: time.sleep(3))
|
| 71 |
+
line = watchdog.render()
|
| 72 |
+
assert "per call <= 0.05s" in line
|
| 73 |
+
assert "wall clock <= 60s" in line
|
| 74 |
+
assert "abandoned: decide" in line
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_the_clock_starts_once_and_is_not_restarted_by_a_re_run():
|
| 78 |
+
watchdog = Watchdog(wall_clock=60).start()
|
| 79 |
+
time.sleep(0.02)
|
| 80 |
+
first = watchdog.elapsed
|
| 81 |
+
watchdog.start() # re-running a goal must not hand it a fresh budget
|
| 82 |
+
assert watchdog.elapsed >= first
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_remaining_counts_down_and_then_reports_expired():
|
| 86 |
+
watchdog = Watchdog(wall_clock=0.05).start()
|
| 87 |
+
assert watchdog.remaining is not None and watchdog.remaining <= 0.05
|
| 88 |
+
time.sleep(0.06)
|
| 89 |
+
assert watchdog.expired is True
|
| 90 |
+
assert Watchdog(wall_clock=None).remaining is None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# -- the loop: a hung stage becomes a step, not a stall ------------------------- #
|
| 94 |
+
|
| 95 |
+
class SlowExecutor(DryRunExecutor):
|
| 96 |
+
seconds = 2.0
|
| 97 |
+
|
| 98 |
+
def execute(self, action, observation):
|
| 99 |
+
time.sleep(self.seconds)
|
| 100 |
+
return super().execute(action, observation)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class SlowScreen(FakeScreen):
|
| 104 |
+
def capture(self):
|
| 105 |
+
time.sleep(2.0)
|
| 106 |
+
return super().capture()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class SlowBrain(MockBrain):
|
| 110 |
+
def decide(self, goal, observation):
|
| 111 |
+
time.sleep(2.0)
|
| 112 |
+
return super().decide(goal, observation)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _screen():
|
| 116 |
+
return FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_a_hung_executor_ends_the_episode_with_a_named_reason():
|
| 120 |
+
loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "click", "label": "Save"}]),
|
| 121 |
+
executor=SlowExecutor(), watchdog=Watchdog(step_timeout=0.05))
|
| 122 |
+
started = time.monotonic()
|
| 123 |
+
episode = loop.run("click 'Save' to save the document")
|
| 124 |
+
assert time.monotonic() - started < 2.0, "the loop must not wait for the hung call"
|
| 125 |
+
|
| 126 |
+
assert episode.success is False
|
| 127 |
+
assert episode.stop_reason.startswith("watchdog stopped the episode")
|
| 128 |
+
assert "execute did not finish within 0.05s" in episode.steps[-1].error
|
| 129 |
+
assert loop.watchdog.orphans == ["execute"]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_a_hung_brain_ends_the_episode_too():
|
| 133 |
+
loop = AgentLoop(screen=_screen(), brain=SlowBrain([{"kind": "done", "reason": "x"}]),
|
| 134 |
+
watchdog=Watchdog(step_timeout=0.05))
|
| 135 |
+
episode = loop.run("save")
|
| 136 |
+
assert episode.success is False
|
| 137 |
+
assert "decide did not finish within 0.05s" in episode.steps[-1].error
|
| 138 |
+
assert loop.watchdog.orphans == ["decide"]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_a_hung_capture_is_recorded_before_the_episode_starts():
|
| 142 |
+
loop = AgentLoop(screen=SlowScreen([(Element("Save", x=1, y=1, width=10, height=10),)]),
|
| 143 |
+
brain=MockBrain([{"kind": "done", "reason": "x"}]),
|
| 144 |
+
watchdog=Watchdog(step_timeout=0.05))
|
| 145 |
+
episode = loop.run("save")
|
| 146 |
+
assert episode.success is False
|
| 147 |
+
assert "watchdog stopped the episode" in episode.stop_reason
|
| 148 |
+
assert "capture did not finish within 0.05s" in episode.stop_reason
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_without_a_watchdog_a_slow_but_finite_call_still_completes():
|
| 152 |
+
"""Guarding is opt-in: a local desktop should not pay for a thread per step."""
|
| 153 |
+
loop = AgentLoop(screen=_screen(), brain=MockBrain([{"kind": "done", "reason": "fine"}]),
|
| 154 |
+
executor=SlowExecutor())
|
| 155 |
+
loop.executor.seconds = 0.05
|
| 156 |
+
episode = loop.run("save")
|
| 157 |
+
assert episode.success is True
|
| 158 |
+
assert loop.watchdog is None
|