Publish tests/ from marimo
Browse files- tests/test_act.py +8 -3
- tests/test_live_desktop.py +112 -0
- tests/test_verify.py +99 -0
tests/test_act.py
CHANGED
|
@@ -51,11 +51,16 @@ def test_live_executor_treats_done_as_a_no_op():
|
|
| 51 |
|
| 52 |
|
| 53 |
def test_live_executor_needs_pyautogui_installed():
|
| 54 |
-
"""Real input on a machine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
try:
|
| 56 |
import pyautogui # noqa: F401
|
| 57 |
-
except
|
| 58 |
with pytest.raises(InputUnavailable, match="pyautogui"):
|
| 59 |
PyAutoGuiExecutor().execute(Action("type", text="hello"))
|
| 60 |
else:
|
| 61 |
-
pytest.skip("pyautogui is
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def test_live_executor_needs_pyautogui_installed():
|
| 54 |
+
"""Real input on a machine that cannot run pyautogui fails loudly, not silently.
|
| 55 |
+
|
| 56 |
+
pyautogui installed is not the same as pyautogui usable: with no display it
|
| 57 |
+
raises KeyError('DISPLAY') on import, so this must catch Exception rather than
|
| 58 |
+
ImportError or the failure escapes as a traceback instead of InputUnavailable.
|
| 59 |
+
"""
|
| 60 |
try:
|
| 61 |
import pyautogui # noqa: F401
|
| 62 |
+
except Exception:
|
| 63 |
with pytest.raises(InputUnavailable, match="pyautogui"):
|
| 64 |
PyAutoGuiExecutor().execute(Action("type", text="hello"))
|
| 65 |
else:
|
| 66 |
+
pytest.skip("pyautogui is usable here")
|
tests/test_live_desktop.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The end-to-end test: a real display, real capture, real OCR, a real click.
|
| 2 |
+
|
| 3 |
+
This is the only test that proves the loop drives a *computer* rather than a
|
| 4 |
+
synthetic screenshot. It is opt-in because it moves the real mouse and clicks:
|
| 5 |
+
|
| 6 |
+
TINY_COMPUTER_AGENT_LIVE_TEST=1 python -m pytest tests/test_live_desktop.py
|
| 7 |
+
|
| 8 |
+
Run it on a throwaway desktop (VM, container with Xvfb), never your working
|
| 9 |
+
session. With Xvfb it needs no hardware:
|
| 10 |
+
|
| 11 |
+
Xvfb :99 -screen 0 1280x800x24 &
|
| 12 |
+
DISPLAY=:99 TINY_COMPUTER_AGENT_LIVE_TEST=1 python -m pytest tests/test_live_desktop.py
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import pathlib
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
from tiny_computer_agent.act import PyAutoGuiExecutor
|
| 24 |
+
from tiny_computer_agent.actions import Action
|
| 25 |
+
from tiny_computer_agent.desktop import display_available
|
| 26 |
+
from tiny_computer_agent.observe import GrabScreen
|
| 27 |
+
from tiny_computer_agent.perception import OcrDetector, detector_available
|
| 28 |
+
|
| 29 |
+
OPT_IN = os.environ.get("TINY_COMPUTER_AGENT_LIVE_TEST") == "1"
|
| 30 |
+
|
| 31 |
+
pytestmark = pytest.mark.skipif(
|
| 32 |
+
not OPT_IN,
|
| 33 |
+
reason="moves the real mouse: set TINY_COMPUTER_AGENT_LIVE_TEST=1 on a throwaway desktop",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
APP = '''import pathlib, tkinter as tk
|
| 37 |
+
|
| 38 |
+
MARKER = pathlib.Path("%(marker)s")
|
| 39 |
+
WINDOW = (120, 120)
|
| 40 |
+
BUTTON = (20, 155, 110, 40) # x, y, width, height inside the window
|
| 41 |
+
|
| 42 |
+
root = tk.Tk()
|
| 43 |
+
root.title("Document")
|
| 44 |
+
root.geometry("420x230+%%d+%%d" %% WINDOW)
|
| 45 |
+
root.configure(bg="#f5f6f8")
|
| 46 |
+
title = tk.Label(root, text="Untitled document - unsaved changes", bg="#f5f6f8")
|
| 47 |
+
title.place(x=20, y=18)
|
| 48 |
+
tk.Label(root, text="File name", bg="#f5f6f8").place(x=20, y=70)
|
| 49 |
+
entry = tk.Entry(root)
|
| 50 |
+
entry.place(x=20, y=95, width=300, height=28)
|
| 51 |
+
entry.insert(0, "report.txt")
|
| 52 |
+
|
| 53 |
+
def chosen(what):
|
| 54 |
+
MARKER.write_text(what)
|
| 55 |
+
title.config(text="Saved to disk")
|
| 56 |
+
for widget in buttons:
|
| 57 |
+
widget.place_forget()
|
| 58 |
+
|
| 59 |
+
buttons = [tk.Button(root, text="Save", command=lambda: chosen("Save"))]
|
| 60 |
+
buttons[0].place(x=BUTTON[0], y=BUTTON[1], width=BUTTON[2], height=BUTTON[3])
|
| 61 |
+
root.update()
|
| 62 |
+
root.mainloop()
|
| 63 |
+
'''
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@pytest.fixture
|
| 67 |
+
def save_dialog(tmp_path):
|
| 68 |
+
"""A real Tk window with a real Save button, on the real display."""
|
| 69 |
+
if not display_available()[0]:
|
| 70 |
+
pytest.skip("no display session to drive")
|
| 71 |
+
if not detector_available("ocr")[0]:
|
| 72 |
+
pytest.skip("this test needs OCR to find the button")
|
| 73 |
+
marker = tmp_path / "clicked.txt"
|
| 74 |
+
app = tmp_path / "app.py"
|
| 75 |
+
app.write_text(APP % {"marker": marker})
|
| 76 |
+
process = subprocess.Popen([sys.executable, str(app)])
|
| 77 |
+
try:
|
| 78 |
+
time.sleep(2.5) # let Tk map the window
|
| 79 |
+
yield marker, (120, 120), (20, 155, 110, 40)
|
| 80 |
+
finally:
|
| 81 |
+
process.terminate()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_ocr_finds_a_real_button_at_the_right_pixels(save_dialog):
|
| 85 |
+
"""Pixels in, a label whose coordinates really are the button's."""
|
| 86 |
+
marker, window, (bx, by, bw, bh) = save_dialog
|
| 87 |
+
observation = GrabScreen(monitor=1).capture()
|
| 88 |
+
elements = OcrDetector().detect(observation.frame)
|
| 89 |
+
|
| 90 |
+
save = [e for e in elements if e.matches("Save")]
|
| 91 |
+
assert save, f"OCR found no Save button, only {[e.label for e in elements]}"
|
| 92 |
+
centre = save[0].center
|
| 93 |
+
assert window[0] + bx <= centre[0] <= window[0] + bx + bw, f"{centre} outside the button"
|
| 94 |
+
assert window[1] + by <= centre[1] <= window[1] + by + bh, f"{centre} outside the button"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_a_real_click_lands_on_the_button(save_dialog):
|
| 98 |
+
"""The whole loop: see it, choose it, click it, and the app reacts."""
|
| 99 |
+
from tiny_computer_agent import AgentLoop, PlannerBrain, Policy
|
| 100 |
+
|
| 101 |
+
marker, _, _ = save_dialog
|
| 102 |
+
episode = AgentLoop(
|
| 103 |
+
screen=GrabScreen(monitor=1),
|
| 104 |
+
brain=PlannerBrain(),
|
| 105 |
+
executor=PyAutoGuiExecutor(),
|
| 106 |
+
policy=Policy(dry_run=False, step_budget=2),
|
| 107 |
+
verify=lambda observation: "saved" in observation.text.lower(),
|
| 108 |
+
).run("click 'Save' to save the document")
|
| 109 |
+
|
| 110 |
+
assert marker.exists(), "no click landed on the button"
|
| 111 |
+
assert marker.read_text() == "Save"
|
| 112 |
+
assert episode.success, episode.render()
|
tests/test_verify.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Success must be evidence, not a claim."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from tiny_computer_agent import (
|
| 6 |
+
Action, AgentLoop, DryRunExecutor, Element, MockBrain, Observation, Policy,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ScriptedTextScreen:
|
| 11 |
+
"""Screen whose *text* changes, so a verifier has something to see."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, texts):
|
| 14 |
+
self.texts = list(texts)
|
| 15 |
+
self.captures = 0
|
| 16 |
+
|
| 17 |
+
def capture(self) -> Observation:
|
| 18 |
+
text = self.texts[min(self.captures, len(self.texts) - 1)]
|
| 19 |
+
self.captures += 1
|
| 20 |
+
return Observation(index=self.captures - 1, elements=(Element("Save"),), text=text)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
WAITS = [{"kind": "wait", "seconds": 0.1}] * 4
|
| 24 |
+
# A script longer than the budget, so the budget - not a scripted "done" - ends the run.
|
| 25 |
+
ENDLESS_WAITS = WAITS * 3
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def run_with_budget(screen, brain, verify=None, budget=3):
|
| 29 |
+
return AgentLoop(screen=screen, brain=brain, policy=Policy(step_budget=budget),
|
| 30 |
+
verify=verify).run("save")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_without_a_verifier_a_successful_action_can_look_like_a_failure():
|
| 34 |
+
"""The bug this feature exists for: the click worked, the episode still failed."""
|
| 35 |
+
changing = ScriptedTextScreen(["one", "two", "three", "four"])
|
| 36 |
+
episode = run_with_budget(changing, MockBrain(ENDLESS_WAITS))
|
| 37 |
+
assert episode.success is False
|
| 38 |
+
assert "budget" in episode.stop_reason
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_a_verifier_turns_evidence_into_success():
|
| 42 |
+
screen = ScriptedTextScreen(["Unsaved changes", "before", "Saved"])
|
| 43 |
+
episode = AgentLoop(
|
| 44 |
+
screen=screen, brain=MockBrain(WAITS), verify=lambda obs: "saved" in obs.text.lower()
|
| 45 |
+
).run("save the document")
|
| 46 |
+
|
| 47 |
+
assert episode.success is True
|
| 48 |
+
assert episode.stop_reason == "verified after 2 action(s)"
|
| 49 |
+
assert len(episode.steps) == 2
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_a_verifier_that_never_matches_leaves_it_a_failure():
|
| 53 |
+
episode = AgentLoop(
|
| 54 |
+
screen=ScriptedTextScreen(["nothing to see"]), brain=MockBrain(WAITS),
|
| 55 |
+
verify=lambda obs: "saved" in obs.text.lower(),
|
| 56 |
+
).run("save")
|
| 57 |
+
assert episode.success is False
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_a_broken_verifier_cannot_fake_a_success():
|
| 61 |
+
def explode(observation):
|
| 62 |
+
raise RuntimeError("verifier bug")
|
| 63 |
+
|
| 64 |
+
episode = run_with_budget(
|
| 65 |
+
ScriptedTextScreen(["one", "two", "three", "four"]), MockBrain(ENDLESS_WAITS), verify=explode
|
| 66 |
+
)
|
| 67 |
+
assert episode.success is False
|
| 68 |
+
assert "budget" in episode.stop_reason
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_a_model_claiming_done_still_wins_when_there_is_no_verifier():
|
| 72 |
+
"""Unchanged behaviour: with no verifier the brain's "done" is all we have."""
|
| 73 |
+
episode = AgentLoop(
|
| 74 |
+
screen=ScriptedTextScreen(["x"]),
|
| 75 |
+
brain=MockBrain([{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "saved it"}]),
|
| 76 |
+
).run("save")
|
| 77 |
+
assert episode.success is True and "saved it" in episode.stop_reason
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_verify_does_not_disturb_dry_run():
|
| 81 |
+
executor = DryRunExecutor()
|
| 82 |
+
AgentLoop(
|
| 83 |
+
screen=ScriptedTextScreen(["a", "b", "Saved"]), brain=MockBrain(WAITS), executor=executor,
|
| 84 |
+
verify=lambda obs: "saved" in obs.text.lower(),
|
| 85 |
+
).run("save")
|
| 86 |
+
assert all(a.kind == "wait" for a in executor.performed)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_cli_verify_text_flag(capsys):
|
| 90 |
+
from tiny_computer_agent import __main__ as cli
|
| 91 |
+
|
| 92 |
+
# the demo screen's text says "Untitled document - unsaved changes"
|
| 93 |
+
assert cli.main(["--script", '[{"kind": "wait", "seconds": 0.1}]',
|
| 94 |
+
"--verify-text", "Unsaved", "--max-steps", "2"]) == 0
|
| 95 |
+
out = capsys.readouterr().out
|
| 96 |
+
assert "verified after" in out
|
| 97 |
+
|
| 98 |
+
# without it, the same run exhausts its budget and fails
|
| 99 |
+
assert cli.main(["--script", '[{"kind": "wait", "seconds": 0.1}]', "--max-steps", "1"]) == 1
|