Publish tests/ from marimo
Browse files- tests/test_act.py +61 -0
- tests/test_actions.py +54 -0
- tests/test_cli.py +33 -0
- tests/test_loop.py +135 -0
- tests/test_observe.py +54 -0
- tests/test_safety.py +55 -0
tests/test_act.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import (
|
| 4 |
+
Action, DryRunExecutor, Element, InputUnavailable, Observation,
|
| 5 |
+
PyAutoGuiExecutor, resolve_point,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
FRAME = Observation(elements=(Element("Save", x=320, y=120, width=80, height=24),))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_resolve_point_uses_pixels_when_given():
|
| 12 |
+
assert resolve_point(Action("click", x=1, y=2), FRAME) == (1, 2)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_resolve_point_uses_the_element_index_for_labels():
|
| 16 |
+
assert resolve_point(Action("click", label="Save"), FRAME) == (360, 132)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_resolve_point_is_none_for_unmatched_labels_and_other_verbs():
|
| 20 |
+
assert resolve_point(Action("click", label="Quit"), FRAME) is None
|
| 21 |
+
assert resolve_point(Action("type", text="hi"), FRAME) is None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_dry_run_records_instead_of_acting():
|
| 25 |
+
executor = DryRunExecutor()
|
| 26 |
+
result = executor.execute(Action("click", label="Save"), FRAME)
|
| 27 |
+
assert result.ok
|
| 28 |
+
assert "(dry-run)" in result.note
|
| 29 |
+
assert "(360, 132)" in result.note
|
| 30 |
+
assert len(executor.performed) == 1
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_dry_run_fails_where_live_input_would_refuse():
|
| 34 |
+
"""A dry run must never give false confidence about a doomed click."""
|
| 35 |
+
dry = DryRunExecutor().execute(Action("click", label="Quit"), FRAME)
|
| 36 |
+
live = PyAutoGuiExecutor().execute(Action("click", label="Quit"), FRAME)
|
| 37 |
+
assert dry.ok is False and live.ok is False
|
| 38 |
+
assert "absent from the element index" in dry.note
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_live_executor_refuses_to_guess_pixels():
|
| 42 |
+
"""The dangerous path: a label with no element behind it must never click."""
|
| 43 |
+
result = PyAutoGuiExecutor().execute(Action("click", label="Quit"), FRAME)
|
| 44 |
+
assert result.ok is False
|
| 45 |
+
assert "refusing to guess pixels" in result.note
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_live_executor_treats_done_as_a_no_op():
|
| 49 |
+
result = PyAutoGuiExecutor().execute(Action("done", reason="finished"))
|
| 50 |
+
assert result.ok and "goal reported met" in result.note
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_live_executor_needs_pyautogui_installed():
|
| 54 |
+
"""Real input on a machine without pyautogui fails loudly, not silently."""
|
| 55 |
+
try:
|
| 56 |
+
import pyautogui # noqa: F401
|
| 57 |
+
except ImportError:
|
| 58 |
+
with pytest.raises(InputUnavailable, match="pyautogui"):
|
| 59 |
+
PyAutoGuiExecutor().execute(Action("type", text="hello"))
|
| 60 |
+
else:
|
| 61 |
+
pytest.skip("pyautogui is installed here")
|
tests/test_actions.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import ACTION_KINDS, ACTION_MENU, Action, ActionError, parse_action
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_click_by_label():
|
| 7 |
+
action = Action("click", label="Save")
|
| 8 |
+
assert action.kind == "click"
|
| 9 |
+
assert "label='Save'" in action.describe()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_click_by_pixels():
|
| 13 |
+
assert Action("click", x=10, y=20).x == 10
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.mark.parametrize(
|
| 17 |
+
"kwargs, message",
|
| 18 |
+
[
|
| 19 |
+
({"kind": "fly"}, "unknown action"),
|
| 20 |
+
({"kind": "click"}, "click needs a label"),
|
| 21 |
+
({"kind": "click", "x": -1, "y": 5}, "non-negative"),
|
| 22 |
+
({"kind": "type", "text": ""}, "type needs non-empty text"),
|
| 23 |
+
({"kind": "key"}, "key needs a chord"),
|
| 24 |
+
({"kind": "scroll", "amount": 0}, "non-zero amount"),
|
| 25 |
+
({"kind": "wait", "seconds": 0}, "positive number of seconds"),
|
| 26 |
+
],
|
| 27 |
+
)
|
| 28 |
+
def test_validation_rejects_bad_actions(kwargs, message):
|
| 29 |
+
with pytest.raises(ActionError, match=message):
|
| 30 |
+
Action(**kwargs)
|
| 31 |
+
|
| 32 |
+
# the same rule must hold for anything a model sends back
|
| 33 |
+
with pytest.raises(ActionError):
|
| 34 |
+
parse_action(kwargs)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_parse_action_ignores_chatty_extra_keys():
|
| 38 |
+
action = parse_action({"kind": "type", "text": "hello", "confidence": 0.9, "thought": "..."})
|
| 39 |
+
assert action.text == "hello"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_parse_action_rejects_a_non_mapping():
|
| 43 |
+
with pytest.raises(ActionError, match="expected a mapping"):
|
| 44 |
+
parse_action(["click", "Save"])
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_menu_documents_every_verb():
|
| 48 |
+
for kind in ACTION_KINDS:
|
| 49 |
+
assert f'"kind": "{kind}"' in ACTION_MENU
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_round_trip_to_dict():
|
| 53 |
+
action = Action("scroll", amount=-3, reason="more content below")
|
| 54 |
+
assert parse_action(action.to_dict()).amount == -3
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import __main__ as cli
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_demo_runs_dry_run_by_default(capsys):
|
| 7 |
+
assert cli.main([]) == 0
|
| 8 |
+
out = capsys.readouterr().out
|
| 9 |
+
assert "dry-run" in out
|
| 10 |
+
assert "(dry-run) would click" in out
|
| 11 |
+
assert "result: success" in out
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_real_input_needs_the_allow_env_var(monkeypatch, capsys):
|
| 15 |
+
monkeypatch.delenv(cli.ALLOW_REAL_INPUT_ENV, raising=False)
|
| 16 |
+
assert cli.main(["--real-input"]) == 2
|
| 17 |
+
assert "refusing --real-input" in capsys.readouterr().err
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_bad_script_json_is_reported(capsys):
|
| 21 |
+
assert cli.main(["--script", "{not json"]) == 2
|
| 22 |
+
assert "not valid JSON" in capsys.readouterr().err
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_script_replay(capsys):
|
| 26 |
+
assert cli.main(["--script", '[{"kind": "done", "reason": "nothing to do"}]']) == 0
|
| 27 |
+
assert "nothing to do" in capsys.readouterr().out
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_failure_exit_code(capsys):
|
| 31 |
+
# the planner gives up when the target never appears, so the CLI exits non-zero
|
| 32 |
+
assert cli.main(["click 'Nonexistent button'"]) == 1
|
| 33 |
+
assert "gave up" in capsys.readouterr().out
|
tests/test_loop.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import (
|
| 4 |
+
Action, AgentLoop, DryRunExecutor, Element, Episode, FakeScreen,
|
| 5 |
+
GoalUnreachable, MockBrain, PlannerBrain, Policy,
|
| 6 |
+
)
|
| 7 |
+
from tiny_computer_agent.__main__ import demo_screen
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_scripted_episode_succeeds_and_records_every_step():
|
| 11 |
+
screen = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
|
| 12 |
+
brain = MockBrain([
|
| 13 |
+
{"kind": "type", "text": "report"},
|
| 14 |
+
{"kind": "key", "keys": "ctrl+s"},
|
| 15 |
+
{"kind": "done", "reason": "file saved"},
|
| 16 |
+
])
|
| 17 |
+
episode = AgentLoop(screen=screen, brain=brain).run("save the report")
|
| 18 |
+
|
| 19 |
+
assert episode.success
|
| 20 |
+
assert [s.action.kind for s in episode.steps] == ["type", "key", "done"]
|
| 21 |
+
assert "file saved" in episode.stop_reason
|
| 22 |
+
assert len(episode.to_rows()) == 3
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_planner_solves_a_real_click_task_with_no_model():
|
| 26 |
+
episode = AgentLoop(screen=demo_screen(), brain=PlannerBrain()).run(
|
| 27 |
+
"click 'Save' to save the document"
|
| 28 |
+
)
|
| 29 |
+
assert episode.success
|
| 30 |
+
clicks = [s.action for s in episode.steps if s.action.kind == "click"]
|
| 31 |
+
assert [c.label for c in clicks] == ["Save"]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_screen_is_reread_after_every_executed_action():
|
| 35 |
+
"""The verify half of the loop: never decide twice on the same frame."""
|
| 36 |
+
screen = FakeScreen([(Element("Save"),)])
|
| 37 |
+
brain = MockBrain([{"kind": "click", "label": "Save"}, {"kind": "click", "label": "Save"}])
|
| 38 |
+
episode = AgentLoop(screen=screen, brain=brain).run("click Save")
|
| 39 |
+
|
| 40 |
+
executed = [s for s in episode.steps if s.result is not None and s.action.kind != "done"]
|
| 41 |
+
assert len(executed) == 2
|
| 42 |
+
assert screen.captures == len(executed) + 1 # one initial read, one per action
|
| 43 |
+
assert episode.steps[0].observation.index == 0
|
| 44 |
+
assert episode.steps[1].observation.index == 1
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_step_budget_is_enforced():
|
| 48 |
+
screen = FakeScreen([(Element("Save"),)])
|
| 49 |
+
brain = MockBrain([{"kind": "click", "label": "Save"}] * 10)
|
| 50 |
+
episode = AgentLoop(
|
| 51 |
+
screen=screen, brain=brain, policy=Policy(step_budget=3)
|
| 52 |
+
).run("click Save")
|
| 53 |
+
|
| 54 |
+
assert episode.success is False
|
| 55 |
+
assert len(episode.steps) == 3
|
| 56 |
+
assert "budget of 3" in episode.stop_reason
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_policy_refusal_stops_the_run_and_nothing_executes():
|
| 60 |
+
executor = DryRunExecutor()
|
| 61 |
+
episode = AgentLoop(
|
| 62 |
+
screen=FakeScreen([(Element("Terminal"),)]),
|
| 63 |
+
brain=MockBrain([{"kind": "type", "text": "sudo rm -rf /"}]),
|
| 64 |
+
executor=executor,
|
| 65 |
+
).run("clean up")
|
| 66 |
+
|
| 67 |
+
assert episode.success is False
|
| 68 |
+
assert "policy refusal" in episode.stop_reason
|
| 69 |
+
assert episode.steps[-1].error.startswith("refused:")
|
| 70 |
+
assert executor.performed == []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_kill_switch_aborts_before_the_first_action(tmp_path):
|
| 74 |
+
marker = tmp_path / "STOP"
|
| 75 |
+
marker.write_text("stop")
|
| 76 |
+
episode = AgentLoop(
|
| 77 |
+
screen=FakeScreen([(Element("Save"),)]),
|
| 78 |
+
brain=MockBrain([{"kind": "click", "label": "Save"}]),
|
| 79 |
+
policy=Policy(kill_switch_path=str(marker)),
|
| 80 |
+
).run("click Save")
|
| 81 |
+
|
| 82 |
+
assert episode.steps == []
|
| 83 |
+
assert episode.stop_reason == "kill switch engaged"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_a_broken_brain_stops_the_run_instead_of_crashing_it():
|
| 87 |
+
class Exploding:
|
| 88 |
+
name = "exploding"
|
| 89 |
+
|
| 90 |
+
def decide(self, goal, observation):
|
| 91 |
+
raise RuntimeError("model died")
|
| 92 |
+
|
| 93 |
+
episode = AgentLoop(screen=FakeScreen([(Element("Save"),)]), brain=Exploding()).run("save")
|
| 94 |
+
|
| 95 |
+
assert episode.success is False
|
| 96 |
+
assert episode.stop_reason == "brain error"
|
| 97 |
+
assert "RuntimeError: model died" in episode.steps[-1].error
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_failed_action_ends_the_episode():
|
| 101 |
+
episode = AgentLoop(
|
| 102 |
+
screen=FakeScreen([(Element("Save"),)]),
|
| 103 |
+
brain=MockBrain([{"kind": "click", "label": "Nowhere"}]),
|
| 104 |
+
).run("click Nowhere")
|
| 105 |
+
|
| 106 |
+
assert episode.success is False
|
| 107 |
+
assert "action failed" in episode.stop_reason
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_on_step_callback_sees_every_step_and_render_mentions_the_goal():
|
| 111 |
+
seen = []
|
| 112 |
+
episode = AgentLoop(
|
| 113 |
+
screen=demo_screen(), brain=PlannerBrain(), on_step=seen.append
|
| 114 |
+
).run("press Save")
|
| 115 |
+
|
| 116 |
+
assert len(seen) == len(episode.steps)
|
| 117 |
+
assert "press Save" in episode.render()
|
| 118 |
+
assert "success" in episode.render()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_a_brain_that_gives_up_is_a_failure_not_a_success():
|
| 122 |
+
"""The harness owns success: "I give up" must never read as "goal met"."""
|
| 123 |
+
episode = AgentLoop(
|
| 124 |
+
screen=FakeScreen([(Element("Files"),)]),
|
| 125 |
+
brain=PlannerBrain(max_waits=0),
|
| 126 |
+
).run("click 'Save'")
|
| 127 |
+
|
| 128 |
+
assert episode.success is False
|
| 129 |
+
assert episode.stop_reason.startswith("brain gave up:")
|
| 130 |
+
assert episode.steps[-1].error.startswith("gave up:")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_planner_raises_rather_than_inventing_a_target():
|
| 134 |
+
with pytest.raises(GoalUnreachable, match="no element name"):
|
| 135 |
+
PlannerBrain().decide("do the thing", FakeScreen([(Element("Save"),)]).capture())
|
tests/test_observe.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import Element, FakeScreen, Observation
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_element_center_is_the_middle_of_the_box():
|
| 7 |
+
assert Element("Save", x=320, y=120, width=80, height=24).center == (360, 132)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_element_center_falls_back_to_corner_without_size():
|
| 11 |
+
assert Element("dot", x=5, y=7).center == (5, 7)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_element_rejects_empty_label():
|
| 15 |
+
with pytest.raises(ValueError, match="non-empty"):
|
| 16 |
+
Element(" ")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_element_rejects_negative_geometry():
|
| 20 |
+
with pytest.raises(ValueError, match="non-negative"):
|
| 21 |
+
Element("Save", x=-5)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_fake_screen_advances_then_holds_the_last_frame():
|
| 25 |
+
screen = FakeScreen([(Element("A"),), (Element("B"),)])
|
| 26 |
+
assert [e.label for f in range(4) for e in screen.capture().elements] == ["A", "B", "B", "B"]
|
| 27 |
+
assert screen.captures == 4
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_find_prefers_exact_match_over_substring():
|
| 31 |
+
observation = Observation(elements=(
|
| 32 |
+
Element("Save as"), Element("Save"), Element("Save all"),
|
| 33 |
+
))
|
| 34 |
+
assert observation.find("Save").label == "Save" # exact wins
|
| 35 |
+
assert observation.find("save as").label == "Save as" # case-insensitive
|
| 36 |
+
assert observation.find("ave a").label == "Save as" # substring fallback
|
| 37 |
+
assert observation.find("Quit") is None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_summary_lists_elements_and_truncates():
|
| 41 |
+
observation = Observation(
|
| 42 |
+
index=2,
|
| 43 |
+
elements=tuple(Element(f"button {i}") for i in range(5)),
|
| 44 |
+
text=" Untitled\n document ",
|
| 45 |
+
)
|
| 46 |
+
summary = observation.summary(max_elements=3)
|
| 47 |
+
assert "step 2" in summary
|
| 48 |
+
assert "Untitled document" in summary # whitespace collapsed
|
| 49 |
+
assert "button 0" in summary
|
| 50 |
+
assert "and 2 more" in summary
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_summary_admits_when_there_is_no_index():
|
| 54 |
+
assert "no element index" in Observation().summary()
|
tests/test_safety.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import (
|
| 4 |
+
Action, Element, Observation, Policy, PolicyViolation,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
FRAME = Observation(elements=(Element("Terminal"), Element("Save")))
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_allows_an_ordinary_click():
|
| 11 |
+
Policy().check(Action("click", label="Save"), FRAME)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_refuses_a_denied_key_chord():
|
| 15 |
+
with pytest.raises(PolicyViolation, match="deny list"):
|
| 16 |
+
Policy().check(Action("key", keys="ctrl+alt+t"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.mark.parametrize("text", ["sudo apt install foo", "rm -rf /", "my password is hunter2"])
|
| 20 |
+
def test_refuses_denied_text(text):
|
| 21 |
+
with pytest.raises(PolicyViolation, match="deny list"):
|
| 22 |
+
Policy().check(Action("type", text=text))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_refuses_clicking_a_denied_widget():
|
| 26 |
+
with pytest.raises(PolicyViolation, match="Terminal"):
|
| 27 |
+
Policy().check(Action("click", label="Terminal"), FRAME)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_deny_label_is_case_insensitive():
|
| 31 |
+
with pytest.raises(PolicyViolation):
|
| 32 |
+
Policy().check(Action("click", label="terminal"), FRAME)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_kill_switch_is_off_until_the_file_exists(tmp_path):
|
| 36 |
+
marker = tmp_path / "STOP"
|
| 37 |
+
policy = Policy(kill_switch_path=str(marker))
|
| 38 |
+
assert policy.should_stop() is False
|
| 39 |
+
marker.write_text("stop")
|
| 40 |
+
assert policy.should_stop() is True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_no_kill_switch_path_means_never_stops():
|
| 44 |
+
assert Policy().should_stop() is False
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_rejects_a_useless_budget():
|
| 48 |
+
with pytest.raises(ValueError, match="step_budget"):
|
| 49 |
+
Policy(step_budget=0)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_deny_lists_are_configurable():
|
| 53 |
+
lax = Policy(deny_text_patterns=(), deny_labels=(), deny_keys=())
|
| 54 |
+
lax.check(Action("type", text="sudo rm -rf /"))
|
| 55 |
+
lax.check(Action("click", label="Terminal"), FRAME)
|