This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 "<script>" 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 "<script>" 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 "<script>" 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 "<script>" 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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user