This commit is contained in:
2026-07-06 23:44:18 +08:00
parent a453d9e6ba
commit d899b875ce
23 changed files with 3274 additions and 51 deletions
+118
View File
@@ -0,0 +1,118 @@
"""Unit tests for cloud.sdk.client.CloudClient (task 8.2)."""
from __future__ import annotations
import pytest
from cloud.config import CloudConfig
from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool
from cloud.sdk.api import create_cloud_router
from cloud.sdk.client import CloudClient
from cloud.scheduler import TaskScheduler
from cloud.store import CloudStore
from core.models import Device
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def _client_and_pool(tmp_path):
store = CloudStore(tmp_path / "cloud.sqlite3")
pool = DevicePool(store, _config())
scheduler = TaskScheduler(pool, store, _config())
plugin_registry = PluginRegistry(store)
app = FastAPI()
app.include_router(
create_cloud_router(
pool=pool,
scheduler=scheduler,
plugin_registry=plugin_registry,
)
)
test_client = TestClient(app)
cloud_client = CloudClient("http://testserver", http_client=test_client)
return cloud_client, pool
def test_client_submit_and_get_status_round_trip(tmp_path) -> None:
client, _ = _client_and_pool(tmp_path)
submission = client.submit_task(goal="open settings")
assert "task_id" in submission
task_id = submission["task_id"]
status = client.get_task_status(task_id)
assert status["id"] == task_id
assert status["status"] == "queued"
assert status["goal"] == "open settings"
def test_client_list_devices_and_hosts(tmp_path) -> None:
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="a:8000",
)
devices = client.list_devices()
assert [d["device_id"] for d in devices] == ["dev-1"]
assert devices[0]["host_id"] == "host-a"
hosts = client.list_hosts()
assert [h["host_id"] for h in hosts] == ["host-a"]
def test_client_plugin_listing_and_registration(tmp_path) -> None:
client, _ = _client_and_pool(tmp_path)
assert client.list_plugins() == []
registered = client.register_plugin(
name="demo",
version="1.0.0",
entry_point_kind="tool",
target="cloud.store:CloudStore",
)
assert registered["name"] == "demo"
assert registered["wired"] is False
listed = client.list_plugins()
assert [p["name"] for p in listed] == ["demo"]
def test_client_submit_with_constraints(tmp_path) -> None:
client, pool = _client_and_pool(tmp_path)
pool.sync_host_devices(
"host-a",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
submission = client.submit_task(
goal="x",
driver_type="wda",
capability_tags=[],
)
task_id = submission["task_id"]
status = client.get_task_status(task_id)
assert status["status"] == "queued"
def test_client_unknown_task_raises(tmp_path) -> None:
import httpx
client, _ = _client_and_pool(tmp_path)
with pytest.raises(httpx.HTTPStatusError):
client.get_task_status("does-not-exist")
+106
View File
@@ -0,0 +1,106 @@
"""Composition safety checks (task 9.1).
Verifies that ``cloud/`` is purely additive: every existing module that
``cloud/`` composes (``runtime.task``, ``workflow.runner``, ``driver.registry``,
``api.console``) is itself unchanged by this change, and remains unaware of the
``cloud`` package in its source.
"""
from __future__ import annotations
import importlib
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _module_source(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _existing_module_paths() -> list[Path]:
"""Return source files cloud/ must not edit or import-back into."""
folders = [
"core",
"driver",
"device",
"runtime",
"tools",
"workflow",
"agents",
"storage",
]
files: list[Path] = []
for folder in folders:
root = PROJECT_ROOT / folder
if not root.exists():
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)
return files
def test_existing_modules_do_not_import_cloud() -> None:
"""No composed-over module imports ``cloud`` (cloud is one-directional)."""
offenders: list[str] = []
for path in _existing_module_paths():
try:
source = _module_source(path)
except OSError:
continue
# Look for an actual import of cloud, not the literal word "cloud" in comments.
for line in source.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
if (
"import cloud" in stripped
or "from cloud" in stripped
or "import cloud." in stripped
):
offenders.append(f"{path}: {stripped}")
assert not offenders, (
"cloud/ must compose other packages by import only; the following "
"existing modules import cloud back (forbidden): " + "; ".join(offenders)
)
def test_cloud_dispatch_imports_existing_runners_by_name() -> None:
"""dispatch.py should reference runtime.task.TaskRunner and workflow.runner.WorkflowRunner."""
dispatch = importlib.import_module("cloud.dispatch")
source = _module_source(Path(dispatch.__file__)) # type: ignore[arg-type]
# TaskRunner/WorkflowRunner are referenced via factory callables, not direct
# imports, so we check for the contract being composed over in docstrings/types.
assert "TaskRunner" in source or "task_runner_factory" in source
assert "WorkflowRunner" in source or "workflow_runner_factory" in source
def test_cloud_source_files_exist_only_under_cloud_directory() -> None:
"""The cloud/ change adds files only under cloud/ (and tests/, pyproject.toml, openspec)."""
cloud_dir = PROJECT_ROOT / "cloud"
assert cloud_dir.exists()
expected_files = {
"__init__.py",
"config.py",
"pool.py",
"store.py",
"scheduler.py",
"dispatch.py",
"plugins.py",
"sdk/__init__.py",
"sdk/api.py",
"sdk/client.py",
"sdk/models.py",
}
found: set[str] = set()
for path in cloud_dir.rglob("*.py"):
found.add(str(path.relative_to(cloud_dir)).replace(os.sep, "/"))
missing = expected_files - found
assert not missing, f"missing cloud source files: {sorted(missing)}"
@@ -0,0 +1,194 @@
"""Composition guard: TaskDispatcher composes a real runtime.task.TaskRunner (task 9.2).
Runs a non-mocked, stub-driver-backed TaskRunner instance inside
TaskDispatcher.dispatch()'s goal-based path. Guards against silent drift in
agent-runtime's public ``run(task) -> Task`` contract this change composes over.
"""
from __future__ import annotations
from datetime import UTC, datetime
from cloud.config import CloudConfig
from cloud.dispatch import Assignment, TaskDispatcher
from cloud.scheduler import ScheduledTask, TaskConstraints
from cloud.store import CloudStore
from core.models import Bounds, Scene, SceneElement, Task
from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep, Planner
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.artifact_store import ArtifactStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
class _ScriptedPlanner(Planner):
def __init__(self, steps: list[PlannedStep]) -> None:
self.steps = steps
def plan(self, *, goal, scene, context): # type: ignore[override]
if len(context.step_results) >= len(self.steps):
return []
return [self.steps[len(context.step_results)]]
def goal_reached(self, *, goal, scene, context): # type: ignore[override]
return len(context.step_results) >= len(self.steps) and all(
result.success for result in context.step_results
)
def _scene() -> Scene:
return Scene(
width=10,
height=20,
elements=[
SceneElement(
id="search",
type="input",
text="Search",
bounds=Bounds(1, 2, 4, 4),
)
],
)
def _real_task_runner(tmp_path) -> TaskRunner:
planner = _ScriptedPlanner(
[
PlannedStep(action="tap", description="tap search", args={"x": 3, "y": 4}),
]
)
executor = Executor(
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
return TaskRunner(
planner=planner,
executor=executor,
metadata_store=metadata,
timeline=timeline,
config=TaskRunnerConfig(max_steps=3),
observer=lambda device_id: _scene(),
screenshot_provider=lambda device_id: PNG_10X20,
)
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def test_dispatcher_runs_real_task_runner_to_completion(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
runner = _real_task_runner(tmp_path)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: None,
store=store,
)
# Enqueue a ScheduledTask in 'assigned' state (the precondition for dispatch).
task_id = "task-real"
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="tap the search field",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="tap the search field",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
# The TaskRunner must have observed the assignment's device_id.
# We assert via the executor's recorded outcomes indirectly by confirming
# the loop drove at least one step (metadata store now has the task as completed).
def test_dispatcher_propagates_real_failure(tmp_path) -> None:
"""If the real TaskRunner reports failure, dispatcher records ``failed``."""
class _AlwaysFailingPlanner(Planner):
def plan(self, *, goal, scene, context): # type: ignore[override]
return [
PlannedStep(action="boom", description="will fail", args={}),
]
def goal_reached(self, *, goal, scene, context): # type: ignore[override]
return False
executor = Executor(
tools={
"boom": lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
metadata = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
runner = TaskRunner(
planner=_AlwaysFailingPlanner(),
executor=executor,
metadata_store=metadata,
timeline=timeline,
config=TaskRunnerConfig(max_steps=1),
observer=lambda device_id: _scene(),
screenshot_provider=lambda device_id: PNG_10X20,
)
store = CloudStore(tmp_path / "cloud.sqlite3")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: None,
store=store,
)
task_id = "task-fail"
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="doomed",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="doomed",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
+218
View File
@@ -0,0 +1,218 @@
"""Unit tests for cloud.sdk.api (task 7.8)."""
from __future__ import annotations
import pytest
from cloud.config import CloudConfig
from cloud.plugins import PluginRegistry
from cloud.pool import DevicePool
from cloud.sdk.api import (
AuthProvider,
NullAuthProvider,
Principal,
create_cloud_router,
)
from cloud.scheduler import TaskConstraints, TaskScheduler
from cloud.store import CloudStore
from core.models import Device
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
def _build_app(
tmp_path,
*,
auth_provider: AuthProvider | None = None,
pool: DevicePool | None = None,
scheduler: TaskScheduler | None = None,
plugin_registry: PluginRegistry | None = None,
):
store = CloudStore(tmp_path / "cloud.sqlite3")
pool = pool or DevicePool(store, _config())
scheduler = scheduler or TaskScheduler(pool, store, _config())
plugin_registry = plugin_registry or PluginRegistry(store)
app = FastAPI()
app.include_router(
create_cloud_router(
pool=pool,
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
)
)
return app, pool, scheduler, plugin_registry
def _client_for(app) -> TestClient:
return TestClient(app)
def test_null_auth_provider_allows_submit_and_status_round_trip(tmp_path) -> None:
app, pool, scheduler, _ = _build_app(tmp_path)
# Plant a device so the listing route has something to show.
pool.sync_host_devices(
"host-local",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="10.0.0.1:8000",
)
client = _client_for(app)
submission = client.post("/v1/tasks", json={"goal": "open settings"})
assert submission.status_code == 201, submission.text
task_id = submission.json()["task_id"]
status = client.get(f"/v1/tasks/{task_id}")
assert status.status_code == 200, status.text
body = status.json()
assert body["id"] == task_id
assert body["status"] == "queued"
assert body["goal"] == "open settings"
def test_unknown_task_id_returns_404(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
resp = client.get("/v1/tasks/does-not-exist")
assert resp.status_code == 404, resp.text
def test_device_and_host_listing_reflect_pool_state(tmp_path) -> None:
app, pool, _, _ = _build_app(tmp_path)
pool.sync_host_devices(
"host-a",
[
Device(id="a-dev-1", driver_type="wda", status="idle"), # type: ignore[arg-type]
Device(id="a-dev-2", driver_type="wda", status="busy"), # type: ignore[arg-type]
],
address="a:8000",
)
pool.sync_host_devices(
"host-b",
[Device(id="b-dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
address="b:8000",
)
client = _client_for(app)
devices = client.get("/v1/devices").json()
assert {d["device_id"] for d in devices} == {"a-dev-1", "a-dev-2", "b-dev-1"}
by_host = {d["device_id"]: d["host_id"] for d in devices}
assert by_host == {
"a-dev-1": "host-a",
"a-dev-2": "host-a",
"b-dev-1": "host-b",
}
hosts = client.get("/v1/hosts").json()
assert {h["host_id"] for h in hosts} == {"host-a", "host-b"}
assert all("last_seen_at" in h for h in hosts)
def test_plugin_listing_and_registration_round_trip(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
assert client.get("/v1/plugins").json() == []
payload = {
"name": "demo-tool",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
resp = client.post("/v1/plugins", json=payload)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["name"] == "demo-tool"
assert body["entry_point_kind"] == "tool"
assert body["wired"] is False
listed = client.get("/v1/plugins").json()
assert len(listed) == 1
assert listed[0]["name"] == "demo-tool"
def test_duplicate_plugin_returns_conflict(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
payload = {
"name": "dup",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
first = client.post("/v1/plugins", json=payload)
assert first.status_code == 201
second = client.post("/v1/plugins", json=payload)
assert second.status_code == 409
class _RejectingAuthProvider:
def authenticate(self, request: object) -> Principal | None:
return None
def test_rejecting_auth_provider_blocks_every_route(tmp_path) -> None:
app, _, _, _ = _build_app(tmp_path, auth_provider=_RejectingAuthProvider())
client = _client_for(app)
assert client.post("/v1/tasks", json={"goal": "x"}).status_code == 401
assert client.get("/v1/tasks/whatever").status_code == 401
assert client.get("/v1/devices").status_code == 401
assert client.get("/v1/hosts").status_code == 401
assert client.get("/v1/plugins").status_code == 401
assert client.post("/v1/plugins", json={
"name": "x",
"version": "1",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}).status_code == 401
def test_default_null_auth_provider_is_used_when_omitted(tmp_path) -> None:
# No auth_provider kwarg -> defaults to NullAuthProvider
app, _, _, _ = _build_app(tmp_path)
client = _client_for(app)
# Should NOT 401 (i.e., NullAuthProvider lets everything through).
assert client.get("/v1/plugins").status_code == 200
assert client.get("/v1/devices").status_code == 200
def test_submit_with_constraints(tmp_path) -> None:
app, pool, _, _ = _build_app(tmp_path)
pool.sync_host_devices(
"host-local",
[Device(id="dev-1", driver_type="wda", status="idle")], # type: ignore[arg-type]
)
client = _client_for(app)
resp = client.post(
"/v1/tasks",
json={
"goal": "x",
"constraints": {"driver_type": "wda", "capability_tags": []},
},
)
assert resp.status_code == 201, resp.text
task_id = resp.json()["task_id"]
# And the device should match if we run assign() manually via the scheduler.
status = client.get(f"/v1/tasks/{task_id}").json()
assert status["status"] == "queued"
+127
View File
@@ -0,0 +1,127 @@
"""Unit tests for cloud.store.CloudStore (task 2.5)."""
from __future__ import annotations
import pytest
from cloud.pool import HostRegistration, PooledDevice
from cloud.store import CloudStore
def _pooled(
device_id: str,
host_id: str,
*,
status: str = "idle",
driver_type: str = "wda",
tags: list[str] | None = None,
) -> PooledDevice:
from datetime import UTC, datetime
return PooledDevice(
device_id=device_id,
host_id=host_id,
driver_type=driver_type,
status=status, # type: ignore[arg-type]
capability_tags=list(tags or []),
synced_at=datetime(2026, 1, 1, tzinfo=UTC),
)
def test_upsert_host_and_replace_devices_round_trip(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
ts = datetime(2026, 7, 6, 12, 0, tzinfo=UTC)
store.upsert_host("host-a", address="10.0.0.1:8000", last_seen_at=ts)
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a"), _pooled("dev-2", "host-a", status="busy")],
)
hosts = store.list_hosts()
assert len(hosts) == 1
assert hosts[0].host_id == "host-a"
assert hosts[0].address == "10.0.0.1:8000"
assert hosts[0].last_seen_at == ts
devices = store.list_devices()
assert {d.device_id for d in devices} == {"dev-1", "dev-2"}
by_id = {d.device_id: d for d in devices}
assert by_id["dev-1"].host_id == "host-a"
assert by_id["dev-2"].status == "busy"
fetched = store.get_device("dev-1")
assert fetched is not None
assert fetched.host_id == "host-a"
assert store.get_device("does-not-exist") is None
def test_second_sync_fully_replaces_host_devices(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address=None, last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a"), _pooled("dev-2", "host-a"), _pooled("dev-3", "host-a")],
)
# Second sync: only dev-2 plus a new dev-4. dev-1/dev-3 must be gone.
store.replace_host_devices(
"host-a",
[_pooled("dev-2", "host-a"), _pooled("dev-4", "host-a")],
)
devices = store.list_devices()
assert {d.device_id for d in devices} == {"dev-2", "dev-4"}
assert all(d.host_id == "host-a" for d in devices)
def test_devices_from_two_hosts_coexist(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="a", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.upsert_host("host-b", address="b", last_seen_at=datetime(2026, 1, 2, tzinfo=UTC))
store.replace_host_devices("host-a", [_pooled("a-dev-1", "host-a")])
store.replace_host_devices("host-b", [_pooled("b-dev-1", "host-b"), _pooled("b-dev-2", "host-b")])
devices = store.list_devices()
assert {d.device_id for d in devices} == {"a-dev-1", "b-dev-1", "b-dev-2"}
by_host = {d.device_id: d.host_id for d in devices}
assert by_host == {"a-dev-1": "host-a", "b-dev-1": "host-b", "b-dev-2": "host-b"}
# Replacing host-a's devices must not touch host-b.
store.replace_host_devices("host-a", [_pooled("a-dev-9", "host-a")])
devices = store.list_devices()
assert {d.device_id for d in devices} == {"a-dev-9", "b-dev-1", "b-dev-2"}
def test_upsert_host_preserves_address_when_none(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="10.0.0.1:8000", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
# Subsequent sync with address=None should not clobber the existing address.
store.upsert_host("host-a", address=None, last_seen_at=datetime(2026, 1, 2, tzinfo=UTC))
host = store.get_host("host-a")
assert host is not None
assert host.address == "10.0.0.1:8000"
assert host.last_seen_at == datetime(2026, 1, 2, tzinfo=UTC)
def test_capability_tags_round_trip(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
from datetime import UTC, datetime
store.upsert_host("host-a", address="a", last_seen_at=datetime(2026, 1, 1, tzinfo=UTC))
store.replace_host_devices(
"host-a",
[_pooled("dev-1", "host-a", tags=["ios", "physical"])],
)
device = store.get_device("dev-1")
assert device is not None
assert device.capability_tags == ["ios", "physical"]
+142
View File
@@ -0,0 +1,142 @@
"""Unit tests for cloud.pool.DevicePool (task 3.4)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from cloud.config import CloudConfig
from cloud.pool import DevicePool
from cloud.store import CloudStore
from core.models import Device
def _config(**overrides) -> CloudConfig:
base = {
"sync_interval_seconds": 30,
"stale_after_seconds": 60,
"max_queue_depth": 100,
"default_assignment_strategy": "fifo_match",
"api_version_prefix": "/v1",
"db_path": "cloud/cloud.sqlite3",
}
base.update(overrides)
return CloudConfig(**base)
def _device(device_id: str, *, status: str = "idle", driver_type: str = "wda") -> Device:
return Device(id=device_id, status=status, driver_type=driver_type) # type: ignore[arg-type]
def test_new_host_sync_creates_registration_and_devices(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(),
)
pool.sync_host_devices(
"host-a",
[_device("dev-1"), _device("dev-2", status="busy")],
address="10.0.0.1:8000",
)
hosts = pool.list_hosts()
assert [h.host_id for h in hosts] == ["host-a"]
assert hosts[0].address == "10.0.0.1:8000"
devices = pool.list_devices()
assert {d.device_id for d in devices} == {"dev-1", "dev-2"}
by_id = {d.device_id: d for d in devices}
assert by_id["dev-1"].status == "idle"
assert by_id["dev-2"].status == "busy"
assert all(d.host_id == "host-a" for d in devices)
def test_resync_updates_last_seen_and_replaces_devices(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices("host-a", [_device("dev-1"), _device("dev-2")])
first_hosts = pool.list_hosts()
first_seen = first_hosts[0].last_seen_at
# Force time forward by directly mutating the stored timestamp.
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=10),
)
pool.sync_host_devices("host-a", [_device("dev-3")])
hosts = pool.list_hosts()
devices = pool.list_devices()
assert [h.host_id for h in hosts] == ["host-a"]
assert {d.device_id for d in devices} == {"dev-3"}
assert hosts[0].last_seen_at > first_seen
def test_stale_host_devices_reported_unreachable(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(stale_after_seconds=60),
)
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
# Push the host's last_seen_at beyond the staleness threshold.
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
)
devices = pool.list_devices()
assert len(devices) == 1
assert devices[0].status == "unreachable"
fetched = pool.get_device("dev-1")
assert fetched is not None
assert fetched.status == "unreachable"
def test_resync_after_stale_clears_unreachable(tmp_path) -> None:
pool = DevicePool(
CloudStore(tmp_path / "cloud.sqlite3"),
_config(stale_after_seconds=60),
)
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
pool.store.upsert_host(
"host-a",
address=None,
last_seen_at=datetime.now(UTC) - timedelta(seconds=120),
)
# Stale right now.
assert pool.list_devices()[0].status == "unreachable"
# Host resyncs with a fresh snapshot.
pool.sync_host_devices("host-a", [_device("dev-1", status="idle")])
devices = pool.list_devices()
assert devices[0].status == "idle"
def test_unknown_device_returns_none(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
assert pool.get_device("does-not-exist") is None
def test_empty_pool_returns_empty_list(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
assert pool.list_devices() == []
assert pool.list_hosts() == []
def test_two_hosts_aggregate_into_one_listing(tmp_path) -> None:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices("host-a", [_device("a-dev-1")], address="a")
pool.sync_host_devices("host-b", [_device("b-dev-1"), _device("b-dev-2")], address="b")
devices = pool.list_devices()
assert {d.device_id: d.host_id for d in devices} == {
"a-dev-1": "host-a",
"b-dev-1": "host-b",
"b-dev-2": "host-b",
}
+257
View File
@@ -0,0 +1,257 @@
"""Unit tests for cloud.plugins.PluginRegistry (task 6.9)."""
from __future__ import annotations
import importlib.metadata
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from cloud.plugins import (
DiscoveryResult,
DriverRegistryUnavailableError,
DuplicatePluginError,
PluginManifest,
PluginRegistry,
PluginValidationError,
)
from cloud.store import CloudStore
import driver.registry as driver_registry_module
def _manifest(
*,
name: str = "demo",
version: str = "1.0.0",
entry_point_kind: str = "driver",
target: str = "cloud.store:CloudStore",
) -> PluginManifest:
return PluginManifest(
name=name,
version=version,
entry_point_kind=entry_point_kind, # type: ignore[arg-type]
target=target,
)
def test_valid_manifest_registers(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifest = registry.register(_manifest(entry_point_kind="tool"))
assert manifest.name == "demo"
stored = registry.store.get_plugin("demo")
assert stored is not None
assert stored[0].entry_point_kind == "tool"
def test_unrecognized_entry_point_kind_rejected(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
with pytest.raises(PluginValidationError):
PluginManifest(
name="bad",
version="1.0.0",
entry_point_kind="strategy", # type: ignore[arg-type]
target="cloud.store:CloudStore",
)
def test_duplicate_name_rejected(tmp_path) -> None:
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(entry_point_kind="tool"))
with pytest.raises(DuplicatePluginError):
registry.register(_manifest(entry_point_kind="tool"))
def test_driver_kind_wires_into_register_driver_type(
tmp_path,
monkeypatch,
) -> None:
"""A driver-kind manifest calls driver.registry.register_driver_type."""
calls: list[tuple[str, object]] = []
def fake_register(name: str, builder) -> None:
calls.append((name, builder))
# driver/registry.py does not yet expose register_driver_type in the real
# codebase, so injecting it via monkeypatch simulates the future state
# where it does (per design.md D6 / Open Questions).
monkeypatch.setattr(
driver_registry_module,
"register_driver_type",
fake_register,
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(entry_point_kind="driver", name="custom-driver"))
assert len(calls) == 1
registered_name, registered_builder = calls[0]
assert registered_name == "custom-driver"
assert callable(registered_builder)
stored = registry.store.get_plugin("custom-driver")
assert stored is not None
assert stored[1] is True # wired
def test_driver_kind_raises_when_extension_point_missing(
tmp_path,
monkeypatch,
) -> None:
"""When register_driver_type is not importable, registration fails loudly."""
# Ensure the attribute is genuinely absent, regardless of future state of driver/registry.py.
monkeypatch.delattr(
driver_registry_module,
"register_driver_type",
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
with pytest.raises(DriverRegistryUnavailableError):
registry.register(_manifest(entry_point_kind="driver"))
def test_tool_and_skill_manifests_register_unwired(tmp_path, monkeypatch) -> None:
"""tool/skill-kind manifests must not touch any other registry."""
touched: list[tuple[str, object]] = []
def fail_if_called(name: str, builder) -> None:
touched.append((name, builder))
monkeypatch.setattr(
driver_registry_module,
"register_driver_type",
fail_if_called,
raising=False,
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
registry.register(_manifest(name="a-tool", entry_point_kind="tool"))
registry.register(_manifest(name="a-skill", entry_point_kind="skill"))
assert touched == []
stored_tool = registry.store.get_plugin("a-tool")
stored_skill = registry.store.get_plugin("a-skill")
assert stored_tool is not None and stored_tool[1] is False
assert stored_skill is not None and stored_skill[1] is False
def test_entry_point_discovery_registers_plugin(
tmp_path,
monkeypatch,
) -> None:
"""discover_entry_points() resolves an installed entry point into a manifest."""
fake_ep = SimpleNamespace(
name="installed-plugin",
load=lambda: {
"name": "installed-plugin",
"version": "0.2.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
},
)
monkeypatch.setattr(
importlib.metadata,
"entry_points",
lambda **kwargs: [fake_ep],
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifests = registry.discover_entry_points()
assert [m.name for m in manifests] == ["installed-plugin"]
registry.register(manifests[0])
assert registry.store.get_plugin("installed-plugin") is not None
def test_manifest_file_discovery_registers_valid_and_skips_malformed(
tmp_path,
) -> None:
plugins_dir = tmp_path / "plugins"
(plugins_dir / "good").mkdir(parents=True)
(plugins_dir / "good" / "plugin.json").write_text(
json.dumps(
{
"name": "good",
"version": "1.0.0",
"entry_point_kind": "tool",
"target": "cloud.store:CloudStore",
}
),
encoding="utf-8",
)
(plugins_dir / "bad").mkdir(parents=True)
(plugins_dir / "bad" / "plugin.json").write_text(
"{ not valid json",
encoding="utf-8",
)
(plugins_dir / "ugly").mkdir(parents=True)
(plugins_dir / "ugly" / "plugin.json").write_text(
json.dumps({"name": "ugly"}), # missing required fields
encoding="utf-8",
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
manifests = registry.discover_manifest_files(plugins_dir)
# Only the well-formed manifest is returned; malformed files are skipped silently.
assert [m.name for m in manifests] == ["good"]
# discover() registers the valid manifest and skips the malformed files
# without aborting the whole scan.
result = registry.discover(scan_path=plugins_dir)
assert any(m.name == "good" for m in result.registered)
assert isinstance(result, DiscoveryResult)
def test_discover_combines_entry_points_and_manifest_files(
tmp_path,
monkeypatch,
) -> None:
fake_ep = SimpleNamespace(
name="via-entry-point",
load=lambda: PluginManifest(
name="via-entry-point",
version="1.0.0",
entry_point_kind="tool",
target="cloud.store:CloudStore",
),
)
monkeypatch.setattr(
importlib.metadata,
"entry_points",
lambda **kwargs: [fake_ep],
)
plugins_dir = tmp_path / "plugins"
(plugins_dir / "via-file").mkdir(parents=True)
(plugins_dir / "via-file" / "plugin.json").write_text(
json.dumps(
{
"name": "via-file",
"version": "1.0.0",
"entry_point_kind": "skill",
"target": "cloud.store:CloudStore",
}
),
encoding="utf-8",
)
registry = PluginRegistry(CloudStore(tmp_path / "cloud.sqlite3"))
result = registry.discover(scan_path=plugins_dir)
names = {m.name for m in result.registered}
assert names == {"via-entry-point", "via-file"}
assert result.errors == []
+2
View File
@@ -7,6 +7,8 @@ def test_imports_new_packages() -> None:
for package in (
"agents",
"api",
"cloud",
"cloud.sdk",
"core",
"device",
"driver",
+293
View File
@@ -0,0 +1,293 @@
"""Unit tests for cloud.dispatch.TaskDispatcher (task 5.6)."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from cloud.config import CloudConfig
from cloud.dispatch import (
Assignment,
RemoteDispatchNotSupportedError,
TaskDispatcher,
UnknownWorkflowDefinitionError,
)
from cloud.pool import DevicePool
from cloud.scheduler import ScheduledTask, TaskConstraints
from cloud.store import CloudStore
from core.models import Task
from workflow.models import (
PlannedGoalStep,
WorkflowDefinition,
WorkflowRun,
WorkflowStepResult,
)
def _config() -> CloudConfig:
return CloudConfig(
sync_interval_seconds=30,
stale_after_seconds=60,
max_queue_depth=100,
default_assignment_strategy="fifo_match",
api_version_prefix="/v1",
db_path="cloud/cloud.sqlite3",
)
class _FakeTaskRunner:
"""A stub TaskRunner that records runs and returns a configured status."""
def __init__(self, *, status: str = "completed") -> None:
self._status = status
self.calls: list[Task] = []
def run(self, task: Task) -> Task:
self.calls.append(task)
task.status = self._status # type: ignore[assignment]
if self._status == "completed":
task.completed_at = datetime.now(UTC)
elif self._status == "failed":
task.completed_at = datetime.now(UTC)
task.failure_reason = "stub failure"
return task
class _FakeWorkflowStore:
def __init__(self, definitions: dict[str, WorkflowDefinition] | None = None) -> None:
self._definitions = definitions or {}
def get_definition(self, definition_id: str) -> WorkflowDefinition | None:
return self._definitions.get(definition_id)
class _FakeWorkflowRunner:
def __init__(
self,
*,
status: str = "completed",
store: _FakeWorkflowStore | None = None,
) -> None:
self._status = status
self.store = store or _FakeWorkflowStore()
self.calls: list[tuple[WorkflowDefinition, str]] = []
def run(
self,
definition: WorkflowDefinition,
*,
device_id: str | None = None,
) -> WorkflowRun:
self.calls.append((definition, device_id or ""))
run = WorkflowRun(
definition_id=definition.id,
status=self._status, # type: ignore[arg-type]
current_step_id=definition.entry_step_id,
variables={},
device_id=device_id,
)
return run
def _enqueue_goal_task(store: CloudStore, task_id: str = "task-1") -> str:
store.enqueue_task(
ScheduledTask(
id=task_id,
goal="open settings",
workflow_definition_id=None,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
return task_id
def test_local_goal_dispatch_marks_done(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner(status="completed")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="open settings",
workflow_definition_id=None,
)
)
assert len(runner.calls) == 1
assert runner.calls[0].device_id == "dev-1"
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
def test_local_goal_dispatch_marks_failed(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner(status="failed")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal="open settings",
workflow_definition_id=None,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
def _definition() -> WorkflowDefinition:
return WorkflowDefinition(
name="linear",
entry_step_id="first",
steps=[PlannedGoalStep("first", "do thing")],
)
def _enqueue_workflow_task(store: CloudStore, definition_id: str, task_id: str = "task-wf") -> str:
store.enqueue_task(
ScheduledTask(
id=task_id,
goal=None,
workflow_definition_id=definition_id,
constraints=TaskConstraints(),
status="assigned",
created_at=datetime.now(UTC),
)
)
return task_id
def test_local_workflow_dispatch_runs_definition_and_updates_status(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
definition = _definition()
wf_store = _FakeWorkflowStore({definition.id: definition})
runner = _FakeWorkflowRunner(status="completed", store=wf_store)
task_id = _enqueue_workflow_task(store, definition.id)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: runner,
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id=definition.id,
)
)
assert len(runner.calls) == 1
called_definition, called_device_id = runner.calls[0]
assert called_definition.id == definition.id
assert called_device_id == "dev-1"
task = store.get_task(task_id)
assert task is not None
assert task.status == "done"
def test_local_workflow_dispatch_marks_failed(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
definition = _definition()
wf_store = _FakeWorkflowStore({definition.id: definition})
runner = _FakeWorkflowRunner(status="failed", store=wf_store)
task_id = _enqueue_workflow_task(store, definition.id)
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: runner,
store=store,
)
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id=definition.id,
)
)
task = store.get_task(task_id)
assert task is not None
assert task.status == "failed"
def test_remote_assignment_raises_and_leaves_assigned(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_goal_task(store)
runner = _FakeTaskRunner()
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: runner,
workflow_runner_factory=lambda: _FakeWorkflowRunner(),
store=store,
)
with pytest.raises(RemoteDispatchNotSupportedError):
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-remote",
host_id="host-remote",
goal="open settings",
workflow_definition_id=None,
)
)
# The stubbed runner must not have been called.
assert runner.calls == []
# Status must remain unchanged from its pre-dispatch value.
task = store.get_task(task_id)
assert task is not None
assert task.status == "assigned"
def test_workflow_dispatch_with_unknown_definition_raises(tmp_path) -> None:
store = CloudStore(tmp_path / "cloud.sqlite3")
task_id = _enqueue_workflow_task(store, "missing-def")
dispatcher = TaskDispatcher(
local_host_id="host-local",
task_runner_factory=lambda: _FakeTaskRunner(),
workflow_runner_factory=lambda: _FakeWorkflowRunner(store=_FakeWorkflowStore({})),
store=store,
)
with pytest.raises(UnknownWorkflowDefinitionError):
dispatcher.dispatch(
Assignment(
task_id=task_id,
device_id="dev-1",
host_id="host-local",
goal=None,
workflow_definition_id="missing-def",
)
)
+219
View File
@@ -0,0 +1,219 @@
"""Unit tests for cloud.scheduler.TaskScheduler (task 4.8)."""
from __future__ import annotations
import time
import pytest
from cloud.config import CloudConfig
from cloud.pool import DevicePool
from cloud.scheduler import (
AssignmentStrategy,
FIFO_MATCH_STRATEGY_NAME,
QueueFullError,
ScheduledTask,
TaskConstraints,
TaskScheduler,
TaskSubmissionValidationError,
UnknownAssignmentStrategyError,
)
from cloud.store import CloudStore
from core.models import Device
def _config(**overrides) -> CloudConfig:
base = {
"sync_interval_seconds": 30,
"stale_after_seconds": 60,
"max_queue_depth": 100,
"default_assignment_strategy": "fifo_match",
"api_version_prefix": "/v1",
"db_path": "cloud/cloud.sqlite3",
}
base.update(overrides)
return CloudConfig(**base)
def _device(device_id: str, *, status: str = "idle", driver_type: str = "wda") -> Device:
return Device(id=device_id, status=status, driver_type=driver_type) # type: ignore[arg-type]
def _pool_with_devices(tmp_path, *devices: Device, host_id: str = "host-local") -> DevicePool:
pool = DevicePool(CloudStore(tmp_path / "cloud.sqlite3"), _config())
pool.sync_host_devices(host_id, list(devices))
return pool
def test_submit_enqueues_with_status_queued(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(goal="open settings")
assert isinstance(task_id, str) and task_id
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "queued"
assert task.goal == "open settings"
assert task.workflow_definition_id is None
def test_submit_requires_goal_or_workflow(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config())
with pytest.raises(TaskSubmissionValidationError):
scheduler.submit()
def test_queue_depth_limit_rejects_submission(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
scheduler = TaskScheduler(pool, pool.store, _config(max_queue_depth=2))
scheduler.submit(goal="one")
scheduler.submit(goal="two")
with pytest.raises(QueueFullError):
scheduler.submit(goal="three")
def test_assign_picks_matching_idle_device(tmp_path) -> None:
pool = _pool_with_devices(
tmp_path,
_device("dev-1", driver_type="wda"),
_device("dev-2", status="busy", driver_type="wda"),
)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(driver_type="wda"),
)
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [task_id]
assert assignments[0].device_id == "dev-1"
assert assignments[0].host_id == "host-local"
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "assigned"
assert task.assigned_device_id == "dev-1"
def test_assign_leaves_task_queued_when_no_device_matches(tmp_path) -> None:
pool = _pool_with_devices(tmp_path, _device("dev-1", driver_type="wda"))
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(driver_type="android"),
)
assignments = scheduler.assign()
assert assignments == []
task = pool.store.get_task(task_id)
assert task is not None
assert task.status == "queued"
def test_two_tasks_assigned_in_submission_order_with_one_device(tmp_path) -> None:
pool = _pool_with_devices(tmp_path, _device("dev-1", driver_type="wda"))
scheduler = TaskScheduler(pool, pool.store, _config())
first_id = scheduler.submit(goal="first")
# Ensure a distinct created_at for the second submission so list_queued_tasks
# ordering by (created_at, id) is deterministic.
time.sleep(0.005)
second_id = scheduler.submit(goal="second")
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [first_id]
first_task = pool.store.get_task(first_id)
second_task = pool.store.get_task(second_id)
assert first_task is not None and second_task is not None
assert first_task.status == "assigned"
assert second_task.status == "queued"
def test_unknown_strategy_raises_at_init(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
with pytest.raises(UnknownAssignmentStrategyError):
TaskScheduler(
pool,
pool.store,
_config(default_assignment_strategy="nonexistent"),
)
def test_custom_strategy_can_be_registered(tmp_path) -> None:
"""A new AssignmentStrategy can be plugged in by name without scheduler edits."""
class LastDeviceStrategy:
def select(self, task: ScheduledTask, candidates): # type: ignore[override]
return candidates[-1] if candidates else None
pool = _pool_with_devices(
tmp_path,
_device("dev-1"),
_device("dev-2"),
)
scheduler = TaskScheduler(
pool,
pool.store,
_config(default_assignment_strategy="last"),
strategies={
FIFO_MATCH_STRATEGY_NAME: type(pool).__module__, # placeholder
"last": LastDeviceStrategy(), # type: ignore[dict-item]
},
)
# Replace with the real fifo strategy so other tests' default isn't relied on.
scheduler._strategies[FIFO_MATCH_STRATEGY_NAME] = type( # noqa: SLF001
pool,
).__module__
task_id = scheduler.submit(goal="x")
assignments = scheduler.assign()
assert len(assignments) == 1
assert assignments[0].device_id == "dev-2"
assert assignments[0].task_id == task_id
def test_capability_tag_constraint_filters_candidates(tmp_path) -> None:
pool = _pool_with_devices(tmp_path)
# Manually plant devices with capability_tags by going through the store.
from datetime import UTC, datetime
from cloud.pool import PooledDevice
pool.store.replace_host_devices(
"host-local",
[
PooledDevice(
device_id="dev-1",
host_id="host-local",
driver_type="wda",
status="idle",
capability_tags=["ios"],
synced_at=datetime.now(UTC),
),
PooledDevice(
device_id="dev-2",
host_id="host-local",
driver_type="wda",
status="idle",
capability_tags=["android"],
synced_at=datetime.now(UTC),
),
],
)
scheduler = TaskScheduler(pool, pool.store, _config())
task_id = scheduler.submit(
goal="x",
constraints=TaskConstraints(capability_tags=["android"]),
)
assignments = scheduler.assign()
assert [a.task_id for a in assignments] == [task_id]
assert assignments[0].device_id == "dev-2"