feat(host-agent): make execution history authoritative
Tests / Test failed: 2, passed: 830

This commit is contained in:
2026-07-15 11:46:27 +08:00
parent ccde30e378
commit 77d4813bb2
46 changed files with 890 additions and 3132 deletions
+1 -4
View File
@@ -2,7 +2,7 @@
Verifies that the cloud workspace package is purely additive: every existing
module it composes (``runtime.task``, ``workflow.runner``, ``driver.registry``,
``api.console``) remains unaware of the ``cloud`` package in its source.
``api.mcp``) remains unaware of the ``cloud`` package in its source.
"""
from __future__ import annotations
@@ -38,9 +38,6 @@ def _existing_module_paths() -> list[Path]:
continue
for path in root.rglob("*.py"):
files.append(path)
console = PROJECT_ROOT / "api" / "console.py"
if console.exists():
files.append(console)
mcp = PROJECT_ROOT / "api" / "mcp.py"
if mcp.exists():
files.append(mcp)
-242
View File
@@ -1,242 +0,0 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import pytest
from core.models import Task
from device.manager import DeviceManager
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20, FakeDriver
def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
app = create_app(
manager=manager or DeviceManager(),
metadata_store=metadata_store,
task_runner=runner,
device_config_store=config_store
or DeviceConfigStore(tmp_path / "device_config.sqlite3"),
timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
)
return TestClient(app), metadata_store
def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) -> None:
manager = DeviceManager()
client, metadata_store = _client(tmp_path, manager=manager)
assert client.get("/console/devices").json() == []
assert client.get("/console/tasks").json() == []
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk iPhone",
driver_type="wda",
)
older = Task(
id="task-old",
goal="open settings",
device_id="iphone-1",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
newer = Task(
id="task-new",
goal="search",
device_id="iphone-2",
status="running",
created_at=datetime(2026, 1, 2, tzinfo=UTC),
updated_at=datetime(2026, 1, 2, tzinfo=UTC),
)
metadata_store.create_task(older)
metadata_store.create_task(newer)
devices = client.get("/console/devices").json()
assert devices == [
{
"id": "iphone-1",
"name": "Desk iPhone",
"status": "idle",
"driver_type": "wda",
"connection_info": {},
"capability_tags": [],
}
]
assert [task["id"] for task in client.get("/console/tasks").json()] == [
"task-new",
"task-old",
]
assert [
task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
] == ["task-old"]
assert [
task["id"] for task in client.get("/console/tasks?status=running").json()
] == ["task-new"]
assert client.get("/console/tasks/task-old").json()["goal"] == "open settings"
assert client.get("/console/tasks/missing").status_code == 404
def test_console_timeline_inlines_screenshot_and_handles_empty_history(
tmp_path,
) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
metadata_store.create_task(task)
assert client.get("/console/tasks/task-1/timeline").json() == []
timeline.append(
task_id="task-1",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="tap search",
tool_call={"action": "tap", "args": {"x": 1, "y": 2}},
result={"ok": True},
screenshot=PNG_10X20,
)
records = client.get("/console/tasks/task-1/timeline").json()
assert records[0]["index"] == 1
assert records[0]["image_base64"] == base64.b64encode(PNG_10X20).decode("ascii")
assert client.get("/console/tasks/missing/timeline").status_code == 404
def test_console_timeline_inlines_before_and_after_screenshots(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(Task(id="task-evidence", goal="tap", device_id="phone"))
before = b"before"
after = b"after"
timeline.append(
task_id="task-evidence",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="tap",
tool_call={"action": "tap", "description": "tap search"},
result={"ok": True},
before_screenshot=before,
after_screenshot=after,
)
record = client.get("/console/tasks/task-evidence/timeline").json()[0]
assert record["before_image_base64"] == base64.b64encode(before).decode("ascii")
assert record["after_image_base64"] == base64.b64encode(after).decode("ascii")
assert record["image_base64"] == base64.b64encode(after).decode("ascii")
def test_console_device_registration_and_unregistration(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
manager = DeviceManager()
client, _ = _client(tmp_path, manager=manager, config_store=config_store)
rejected = client.post(
"/console/devices",
json={"driver_type": "android", "connection_info": {}},
)
assert rejected.status_code == 400
assert config_store.list() == []
assert manager.list_devices() == []
response = client.post(
"/console/devices",
json={
"driver_type": "wda",
"name": "Desk iPhone",
"connection_info": {
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
},
},
)
assert response.status_code == 201
device_id = response.json()["id"]
assert response.json()["status"] == "idle"
assert config_store.get(device_id)["connection_info"]["udid"] == "abc123"
assert [device.id for device in manager.list_devices()] == [device_id]
delete_response = client.delete(f"/console/devices/{device_id}")
assert delete_response.status_code == 204
assert config_store.get(device_id) is None
assert manager.list_devices() == []
assert client.delete("/console/devices/missing").status_code == 404
def test_console_config_get_update_and_validation(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 7)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
assert runner.config.max_steps == 7
assert client.get("/console/config").json() == {"max_steps": 7}
response = client.put("/console/config", json={"max_steps": 30})
assert response.status_code == 200
assert response.json() == {"max_steps": 30}
assert runner.config.max_steps == 30
assert config_store.get_setting("max_steps") == "30"
rejected = client.put("/console/config", json={"max_steps": 0})
assert rejected.status_code == 400
assert runner.config.max_steps == 30
assert config_store.get_setting("max_steps") == "30"
def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.add(
device_id="persisted-1",
name="Persisted iPhone",
driver_type="wda",
connection_info={"udid": "abc123"},
)
config_store.set_setting("max_steps", 31)
manager = DeviceManager()
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(
tmp_path,
manager=manager,
runner=runner,
config_store=config_store,
)
assert runner.config.max_steps == 31
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
def test_console_json_reflects_page_form_mutations(tmp_path) -> None:
"""A device registered via the /ui/ form must be visible through /console/* JSON."""
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "From Form",
"driver_type": "wda",
"server_url": "http://127.0.0.1:4723",
"udid": "form-udid",
"wda_local_port": "8100",
},
follow_redirects=False,
)
assert response.status_code == 303
json_devices = client.get("/console/devices").json()
assert len(json_devices) == 1
assert json_devices[0]["name"] == "From Form"
assert json_devices[0]["connection_info"]["udid"] == "form-udid"
+72 -2
View File
@@ -139,7 +139,11 @@ def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
completed_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
metadata_store.create_task(
task,
source_task_id="cloud-task-visible",
source_attempt=2,
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
@@ -148,7 +152,10 @@ def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
response = client.get("/tasks")
assert response.status_code == 200
assert "Executed tasks on this Host" in response.text
assert "task-visible" in response.text
assert "cloud-task-visible" in response.text
assert "2" in response.text
assert "completed" in response.text
@@ -190,6 +197,69 @@ def test_authenticated_task_detail_renders_timeline_with_screenshot(tmp_path) ->
assert "find search button" in response.text
# Screenshot inlined as base64 data URI.
assert "data:image/png;base64," in response.text
assert "Before action" in response.text
assert "After action" in response.text
def test_authenticated_task_detail_renders_complete_step_evidence(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-evidence",
goal="inspect search",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(
task,
source_task_id="cloud-evidence",
source_attempt=3,
)
timeline.append(
task_id=task.id,
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="inspect the current UI tree",
tool_call={"action": "get_ui_tree", "description": "inspect UI tree"},
result={
"result": [
{
"id": "search",
"type": "button",
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 0.98,
}
]
},
before_screenshot=PNG_10X20,
after_screenshot=PNG_10X20 + b"after",
ocr_results=[
{
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 0.95,
}
],
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get(f"/tasks/{task.id}")
assert response.status_code == 200
assert "cloud-evidence" in response.text
assert "Cloud attempt" in response.text
assert "OCR results (1)" in response.text
assert "UI tree (1 normalized nodes)" in response.text
assert "inspect the current UI tree" in response.text
assert response.text.count("data:image/png;base64,") == 2
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
@@ -207,4 +277,4 @@ def test_tasks_list_shows_empty_state(tmp_path) -> None:
response = client.get("/tasks")
assert response.status_code == 200
assert "No tasks recorded" in response.text
assert "No executions recorded yet" in response.text
+52
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -75,6 +76,57 @@ def test_step_transitions_persist_during_execution(tmp_path) -> None:
assert [r["index"] for r in records] == [1, 2]
def test_task_metadata_creation_is_idempotent_and_keeps_source_correlation(
tmp_path,
) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
task = _make_task("task-source")
metadata_store.create_task(
task,
source_task_id="cloud-task-source",
source_attempt=2,
)
metadata_store.create_task(task)
row = metadata_store.get_task(task.id)
assert row is not None
assert row["source_task_id"] == "cloud-task-source"
assert row["source_attempt"] == 2
def test_existing_task_database_gains_source_correlation_columns(tmp_path) -> None:
db_path = tmp_path / "legacy.sqlite3"
with sqlite3.connect(db_path) as connection:
connection.execute(
"""
create table tasks (
id text primary key,
goal text not null,
device_id text not null,
status text not null,
created_at text not null,
updated_at text not null,
completed_at text,
failure_reason text
)
"""
)
metadata_store = TaskMetadataStore(db_path)
task = _make_task("task-migrated")
metadata_store.create_task(
task,
source_task_id="cloud-task-migrated",
source_attempt=1,
)
row = metadata_store.get_task(task.id)
assert row is not None
assert row["source_task_id"] == "cloud-task-migrated"
assert row["source_attempt"] == 1
# --------------------------------------------------------------------------- #
# Queryable after completion (in-memory Task discarded)
# --------------------------------------------------------------------------- #
-43
View File
@@ -1,43 +0,0 @@
from __future__ import annotations
import pytest
from device.manager import DeviceManager
from core.models import Task
from storage.task_metadata import TaskMetadataStore
from tests.fakes import FakeDriver
def test_rest_start_task_and_poll_until_complete(tmp_path) -> None:
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
driver = FakeDriver()
manager = DeviceManager()
manager.register_device("iphone-1", lambda: driver)
manager.connect("iphone-1", max_retries=1)
store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
class CompletingRunner:
def run(self, task: Task) -> None:
store.update_task(task.id, status="completed", completed=True)
app = create_app(
manager=manager,
metadata_store=store,
task_runner=CompletingRunner(),
)
client = TestClient(app)
response = client.post(
"/agent/task",
json={"goal": "search", "device_id": "iphone-1"},
)
assert response.status_code == 200
task_id = response.json()["task_id"]
status_response = client.get(f"/task/{task_id}")
assert status_response.status_code == 200
assert status_response.json()["status"] == "completed"
-79
View File
@@ -1,79 +0,0 @@
"""Smoke test that console templates and assets ship inside the Runtime wheel.
Builds ``device-agent-runtime`` into a temporary directory, installs it into an
isolated venv that cannot reach the source checkout, and asserts the packaged
``api`` package carries the Jinja2 templates and static assets needed by
``/ui/``. This guards against setuptools package-data regressions that would
let the console work from an editable checkout but break from a real install.
"""
from __future__ import annotations
import subprocess
import venv
from pathlib import Path
import pytest
def _run(cmd: list[str], *, cwd: Path | None = None) -> str:
return subprocess.check_output(
cmd,
cwd=cwd,
stderr=subprocess.STDOUT,
text=True,
)
@pytest.mark.integration
def test_runtime_wheel_packages_console_templates_and_assets(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parent.parent
wheel_dir = tmp_path / "wheels"
wheel_dir.mkdir()
_run(
["uv", "build", "--package", "device-agent-runtime", "--wheel", "--no-sources"],
cwd=repo_root,
)
wheels = list(repo_root.glob("dist/*.whl"))
assert wheels, "uv build did not produce a wheel"
wheel_path = wheels[0]
venv_dir = tmp_path / "venv"
venv.create(venv_dir, with_pip=True, clear=True)
pip = str(venv_dir / "Scripts" / "pip.exe")
if not Path(pip).exists():
pip = str(venv_dir / "bin" / "pip")
_run([pip, "install", str(wheel_path)], cwd=tmp_path)
python = str(venv_dir / "Scripts" / "python.exe")
if not Path(python).exists():
python = str(venv_dir / "bin" / "python")
probe = _run(
[
python,
"-c",
(
"from importlib.resources import files; "
"api_root = files('api'); "
"templates = sorted(p.name for p in "
"(api_root / 'templates' / 'runtime_console').iterdir()); "
"assets = sorted(p.name for p in "
"(api_root / 'static' / 'runtime_console').iterdir()); "
"print(','.join(templates)); "
"print(','.join(assets))"
),
],
cwd=tmp_path,
)
template_names, asset_names = probe.strip().splitlines()
assert "base.html" in template_names
assert "dashboard.html" in template_names
assert "config.html" in template_names
assert "console.css" in asset_names
assert "dashboard.js" in asset_names
# Clean up the build artifact so it does not leak into the working tree.
for wheel in wheels:
wheel.unlink()
-447
View File
@@ -1,447 +0,0 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
from pathlib import Path
import pytest
from core.models import Task
from device.manager import DeviceManager
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20, FakeDriver
def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from api.rest import create_app
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
app = create_app(
manager=manager or DeviceManager(),
metadata_store=metadata_store,
task_runner=runner,
device_config_store=config_store
or DeviceConfigStore(tmp_path / "device_config.sqlite3"),
timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
)
return TestClient(app), metadata_store
_TEMPLATE_NAMES = [
"base.html",
"dashboard.html",
"_status_fragment.html",
"tasks.html",
"task_detail.html",
"config.html",
]
# -- 5.2 Template tests ------------------------------------------------------
def test_module_jinja_environment_autoescapes_html_and_xml() -> None:
from api.console_web import _ENV
# select_autoescape(["html", "xml"]) returns a callable used by Jinja2.
assert callable(_ENV.autoescape)
assert _ENV.autoescape("foo.html") is True
assert _ENV.autoescape("foo.xml") is True
def test_every_console_template_is_known_and_loadable() -> None:
from api.console_web import _ENV
for name in _TEMPLATE_NAMES:
assert _ENV.get_template(name) is not None
def test_no_template_uses_safe_filter_bypass() -> None:
template_dir = (
Path(__file__).resolve().parent.parent / "api" / "templates" / "runtime_console"
)
for path in template_dir.glob("*.html"):
source = path.read_text(encoding="utf-8")
assert "| safe" not in source, f"{path.name} uses |safe bypass"
assert "|safe" not in source, f"{path.name} uses |safe bypass"
def test_dashboard_escapes_untrusted_device_name(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"dev-xss",
lambda: FakeDriver(),
name="<script>alert(1)</script>",
driver_type="wda",
)
client, _ = _client(tmp_path, manager=manager)
body = client.get("/ui/").text
assert "&lt;script&gt;" in body
assert "<script>alert(1)</script>" not in body
def test_tasks_list_escapes_untrusted_goal(tmp_path) -> None:
client, metadata_store = _client(tmp_path)
metadata_store.create_task(
Task(
id="task-xss",
goal="<script>alert('xss')</script>",
device_id="dev-1",
)
)
body = client.get("/ui/tasks").text
assert "&lt;script&gt;" in body
assert "<script>alert('xss')</script>" not in body
def test_task_detail_escapes_failure_reason_and_structured_output(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(
id="task-detail",
goal="do thing",
device_id="dev-1",
failure_reason="<script>alert('fail')</script>",
)
)
timeline.append(
task_id="task-detail",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="do thing",
tool_call={"action": "<script>", "args": {"x": 1}},
result={"err": "</script><script>alert(1)</script>"},
screenshot=PNG_10X20,
)
body = client.get("/ui/tasks/task-detail").text
assert "<script>alert('fail')</script>" not in body
assert "</script><script>" not in body
# Structured JSON output uses tojson which escapes angle brackets.
assert "\\u003c" in body or "&lt;script&gt;" in body
def test_config_form_error_preserves_and_escapes_submitted_name(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.post(
"/ui/config/devices",
data={
"name": "<script>alert(1)</script>",
"driver_type": "bad-driver",
},
)
assert response.status_code == 400
assert "<script>alert(1)</script>" not in response.text
assert "&lt;script&gt;" in response.text
# -- 5.3 TestClient page-route coverage --------------------------------------
def test_root_redirects_to_ui(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/ui/"
def test_dashboard_serves_html_with_empty_state(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "No devices registered" in response.text
def test_dashboard_shows_populated_metrics_and_devices(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk iPhone",
driver_type="wda",
)
client, metadata_store = _client(tmp_path, manager=manager)
metadata_store.create_task(
Task(id="t-running", goal="run", device_id="iphone-1", status="running")
)
metadata_store.create_task(
Task(id="t-failed", goal="fail", device_id="iphone-1", status="failed")
)
body = client.get("/ui/").text
assert "Desk iPhone" in body
assert "iphone-1" in body
def test_status_fragment_endpoint_returns_html_partial(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/_status_fragment")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "Device Status" in response.text
def test_tasks_page_supports_device_and_status_filters(tmp_path) -> None:
manager = DeviceManager()
manager.register_device(
"iphone-1",
lambda: FakeDriver(),
name="Desk",
driver_type="wda",
)
client, metadata_store = _client(tmp_path, manager=manager)
older = Task(
id="task-old",
goal="open settings",
device_id="iphone-1",
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
newer = Task(
id="task-new",
goal="search",
device_id="iphone-2",
status="running",
created_at=datetime(2026, 1, 2, tzinfo=UTC),
updated_at=datetime(2026, 1, 2, tzinfo=UTC),
)
metadata_store.create_task(older)
metadata_store.create_task(newer)
body_all = client.get("/ui/tasks").text
assert "task-old" in body_all
assert "task-new" in body_all
body_filtered = client.get("/ui/tasks?device_id=iphone-1").text
assert "task-old" in body_filtered
assert "task-new" not in body_filtered
body_status = client.get("/ui/tasks?status=running").text
assert "task-new" in body_status
assert "task-old" not in body_status
def test_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(id="task-with-timeline", goal="tap search", device_id="iphone-1")
)
timeline.append(
task_id="task-with-timeline",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="tap search",
tool_call={
"action": "tap",
"description": "tap search",
"args": {"x": 1, "y": 2},
},
result={"ok": True},
before_screenshot=PNG_10X20 + b"before",
after_screenshot=PNG_10X20 + b"after",
ocr_results=[
{
"text": "Search",
"confidence": 0.98,
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
}
],
)
body = client.get("/ui/tasks/task-with-timeline").text
before_data_uri = "data:image/png;base64," + base64.b64encode(
PNG_10X20 + b"before"
).decode("ascii")
after_data_uri = "data:image/png;base64," + base64.b64encode(
PNG_10X20 + b"after"
).decode("ascii")
assert before_data_uri in body
assert after_data_uri in body
assert "Before action" in body
assert "After action" in body
assert "Operation" in body
assert "OCR results" in body
assert "Search" in body
assert "UI tree" not in body
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.get("/ui/tasks/does-not-exist")
assert response.status_code == 404
def test_task_detail_renders_normalized_ui_tree_result(tmp_path) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
metadata_store.create_task(
Task(id="task-ui-tree", goal="inspect the screen", device_id="iphone-1")
)
timeline.append(
task_id="task-ui-tree",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="inspect the screen",
tool_call={"action": "get_ui_tree", "description": "inspect UI tree"},
result={
"success": True,
"result": [
{
"id": "ui-000",
"type": "button",
"text": "Search",
"bounds": {"x": 1, "y": 2, "width": 3, "height": 4},
"confidence": 1.0,
}
],
},
)
body = client.get("/ui/tasks/task-ui-tree").text
assert "UI tree" in body
assert "1 normalized nodes" in body
assert "button" in body
assert "Search" in body
def test_config_page_lists_supported_drivers_and_current_max_steps(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 5)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
body = client.get("/ui/config").text
assert "wda" in body
assert 'value="5"' in body
def test_register_device_prg_redirects_and_persists(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "Desk iPhone",
"driver_type": "wda",
"server_url": "http://127.0.0.1:4723",
"udid": "abc123",
"wda_local_port": "8100",
},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"].endswith("/ui/config")
assert len(config_store.list()) == 1
def test_register_device_rejects_bad_driver_without_partial_mutation(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={"name": "Bad", "driver_type": "android"},
)
assert response.status_code == 400
assert config_store.list() == []
assert "unsupported driver_type" in response.text
def test_register_device_rejects_non_numeric_port_without_partial_mutation(
tmp_path,
) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices",
data={
"name": "Bad Port",
"driver_type": "wda",
"wda_local_port": "not-a-number",
},
)
assert response.status_code == 400
assert config_store.list() == []
assert "wda_local_port must be a number" in response.text
def test_remove_device_prg_redirects_and_removes(tmp_path) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.add(
device_id="removable-1",
name="To Remove",
driver_type="wda",
connection_info={"udid": "abc"},
)
client, _ = _client(tmp_path, config_store=config_store)
response = client.post(
"/ui/config/devices/removable-1/delete",
follow_redirects=False,
)
assert response.status_code == 303
assert config_store.list() == []
def test_update_max_steps_prg_redirects_and_applies(tmp_path) -> None:
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner)
response = client.post(
"/ui/config/max-steps",
data={"max_steps": "25"},
follow_redirects=False,
)
assert response.status_code == 303
assert runner.config.max_steps == 25
def test_update_max_steps_rejects_non_positive_without_partial_mutation(
tmp_path,
) -> None:
config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
config_store.set_setting("max_steps", 10)
runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
client, _ = _client(tmp_path, runner=runner, config_store=config_store)
response = client.post("/ui/config/max-steps", data={"max_steps": "0"})
assert response.status_code == 400
assert runner.config.max_steps == 10
assert "max_steps must be positive" in response.text
def test_update_max_steps_rejects_non_integer(tmp_path) -> None:
client, _ = _client(tmp_path)
response = client.post("/ui/config/max-steps", data={"max_steps": "abc"})
assert response.status_code == 400
assert "max_steps must be an integer" in response.text
def test_static_assets_are_served(tmp_path) -> None:
client, _ = _client(tmp_path)
assert client.get("/ui/assets/console.css").status_code == 200
assert client.get("/ui/assets/dashboard.js").status_code == 200
# -- 5.4 Regression: no SPA static dir, no wildcard CORS ---------------------
def test_ui_works_without_runtime_console_static_dir(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("RUNTIME_CONSOLE_STATIC_DIR", raising=False)
client, _ = _client(tmp_path)
assert client.get("/ui/").status_code == 200
def test_runtime_app_does_not_register_wildcard_cors(tmp_path) -> None:
client, _ = _client(tmp_path)
# A same-origin browser client must not require CORS preflight. If wildcard
# CORS were still registered, an explicit Origin header would produce
# access-control-allow-origin in the response; assert it is absent for an
# arbitrary same-origin page request.
response = client.get(
"/ui/",
headers={"Origin": "http://127.0.0.1:8000"},
)
assert response.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in response.headers}
-1
View File
@@ -60,7 +60,6 @@ def test_task_runner_executes_loop_and_writes_timeline(tmp_path) -> None:
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(goal="open app and search", device_id="iphone-1")
metadata.create_task(task)
runner = TaskRunner(
planner=planner,
+35
View File
@@ -6,6 +6,7 @@ from runtime.planner import Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from skills_learning.store import SkillStore
from storage.task_metadata import TaskMetadataStore
from tests.fakes import PNG_10X20
from workflow.models import (
BranchStep,
@@ -39,6 +40,14 @@ class FakeTaskRunner:
return task
class ImmediateCompletionPlanner(Planner):
def plan(self, *, goal, scene, context):
return []
def goal_reached(self, *, goal, scene, context):
return True
def _scene(text: str = "Ready") -> Scene:
return Scene(
width=10,
@@ -96,6 +105,32 @@ def test_workflow_runner_linear_planned_goal_completes(tmp_path) -> None:
assert [result.step_id for result in run.step_results] == ["first", "second"]
def test_workflow_planned_goal_creates_task_metadata_without_precreation(
tmp_path,
) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
definition = WorkflowDefinition(
name="persisted goal",
entry_step_id="goal",
steps=[PlannedGoalStep("goal", "inspect screen")],
)
runner = WorkflowRunner(
_store(tmp_path),
task_runner_factory=lambda: TaskRunner(
planner=ImmediateCompletionPlanner(),
metadata_store=metadata_store,
observer=lambda device_id: _scene(),
config=TaskRunnerConfig(max_steps=1),
),
)
run = runner.run(definition, "phone")
task_id = run.step_results[0].task_id
assert task_id is not None
assert metadata_store.get_task(task_id)["status"] == "completed"
def test_workflow_runner_stops_before_the_next_step(tmp_path) -> None:
stop_requested = False
calls: list[str] = []