Files
EvoScientist/tests/test_cron_schedule.py

226 lines
7.7 KiB
Python

"""Unit tests for the cron wrapper (mock langgraph_sdk client)."""
from unittest.mock import MagicMock
def _patch_client(monkeypatch):
from EvoScientist.cron import schedule as crons
fake = MagicMock()
fake.crons.create.return_value = {"cron_id": "c-1", "schedule": "*/10 * * * *"}
fake.crons.search.return_value = [
{
"cron_id": "c-1",
"schedule": "*/10 * * * *",
"metadata": {"run_kind": "scheduled_task", "name": "weather"},
},
]
monkeypatch.setattr(crons, "_client", lambda: fake)
monkeypatch.setattr(crons, "_default_timezone", lambda: "Europe/London")
return crons, fake
def test_create_schedule_targets_scheduler(monkeypatch):
crons, fake = _patch_client(monkeypatch)
rec = crons.create_schedule(
name="weather",
schedule="*/10 * * * *",
prompt="search uk weather and summarize",
)
assert rec["cron_id"] == "c-1"
kw = fake.crons.create.call_args.kwargs
assert kw["assistant_id"] == crons.SCHEDULER_GRAPH_ID
assert kw["schedule"] == "*/10 * * * *"
assert kw["input"] == {
"messages": [{"role": "user", "content": "search uk weather and summarize"}]
}
assert kw["metadata"]["run_kind"] == crons.SCHEDULED_RUN_KIND
assert kw["metadata"]["name"] == "weather"
assert kw["timezone"] == "Europe/London"
def test_list_schedules_uses_server_side_filter(monkeypatch):
crons, fake = _patch_client(monkeypatch)
out = crons.list_schedules()
assert [c["cron_id"] for c in out] == ["c-1"]
# Filtered server-side by run_kind metadata (no client filter); high limit so
# users with >10 schedules still see them all.
fake.crons.search.assert_called_once_with(
metadata={"run_kind": crons.SCHEDULED_RUN_KIND},
limit=1000,
)
def test_delete_and_set_enabled(monkeypatch):
crons, fake = _patch_client(monkeypatch)
crons.delete_schedule("c-1")
fake.crons.delete.assert_called_once_with("c-1")
crons.set_enabled("c-1", False)
assert fake.crons.update.call_args.kwargs["enabled"] is False
def test_run_now_dispatches_thread_then_run(monkeypatch):
crons, fake = _patch_client(monkeypatch)
fake.threads.create.return_value = {"thread_id": "t-1"}
fake.runs.create.return_value = {"run_id": "r-1"}
rec = crons.run_now("do the thing")
assert rec["run_id"] == "r-1"
fake.threads.create.assert_called_once_with(graph_id=crons.SCHEDULER_GRAPH_ID)
run_kw = fake.runs.create.call_args.kwargs
assert run_kw["thread_id"] == "t-1"
assert run_kw["assistant_id"] == crons.SCHEDULER_GRAPH_ID
assert run_kw["input"] == {
"messages": [{"role": "user", "content": "do the thing"}]
}
assert run_kw["metadata"]["run_kind"] == crons.SCHEDULED_RUN_KIND
assert run_kw["metadata"]["prompt"] == "do the thing"
# ---------------------------------------------------------------------------
# Task 3: scheduler registration tests
# ---------------------------------------------------------------------------
def test_scheduler_yaml_loads_as_async():
"""scheduler.yaml must define scheduler with async:true and a system_prompt."""
from pathlib import Path
import yaml
import EvoScientist
subagents_dir = Path(EvoScientist.__file__).parent / "subagents"
yaml_path = subagents_dir / "scheduler.yaml"
assert yaml_path.exists(), f"Missing {yaml_path}"
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
assert "scheduler" in data, "Top-level key must be 'scheduler'"
spec = data["scheduler"]
assert spec.get("async") is True, "scheduler must have 'async: true'"
assert spec.get("system_prompt"), "scheduler must have a non-empty system_prompt"
# description drives task-tool routing — must be a non-empty string.
assert spec.get("description"), "scheduler must have a non-empty description"
def test_langgraph_json_registers_scheduler():
"""langgraph.json must map scheduler to the graphs:scheduler binding."""
import json
from pathlib import Path
import EvoScientist
root = Path(EvoScientist.__file__).parent
manifest = json.loads(
(root / "langgraph_dev" / "langgraph.json").read_text(encoding="utf-8")
)
assert manifest["graphs"]["scheduler"] == (
"EvoScientist.langgraph_dev.graphs:scheduler"
)
def test_scheduler_graph_id_matches_registration():
"""SCHEDULER_GRAPH_ID must match both langgraph.json and scheduler.yaml top-level key."""
import json
from pathlib import Path
import yaml
import EvoScientist
from EvoScientist.cron import schedule as crons
root = Path(EvoScientist.__file__).parent
manifest = json.loads(
(root / "langgraph_dev" / "langgraph.json").read_text(encoding="utf-8")
)
assert crons.SCHEDULER_GRAPH_ID in manifest["graphs"], (
f"SCHEDULER_GRAPH_ID={crons.SCHEDULER_GRAPH_ID!r} not found in langgraph.json graphs"
)
spec = yaml.safe_load(
(root / "subagents" / "scheduler.yaml").read_text(encoding="utf-8")
)
assert crons.SCHEDULER_GRAPH_ID in spec, (
f"SCHEDULER_GRAPH_ID={crons.SCHEDULER_GRAPH_ID!r} is not the top-level key in scheduler.yaml"
)
def test_production_loaders_accept_utf8_content(tmp_path, monkeypatch):
"""Production config readers must handle localized UTF-8 content."""
import os
from EvoScientist.langgraph_dev import manager
from EvoScientist.mcp import client, registry
mcp_config = tmp_path / "mcp.yaml"
mcp_config.write_text(
"""
écho:
transport: stdio
command: python
args: ["-m", "démo"]
env:
MESSAGE: "bonjour café"
""".lstrip(),
encoding="utf-8",
)
monkeypatch.setattr(client, "USER_MCP_CONFIG", mcp_config)
assert client._load_user_config()["écho"]["env"]["MESSAGE"] == "bonjour café"
assert client.load_mcp_config()["écho"]["args"] == ["-m", "démo"]
marketplace_yaml = tmp_path / "marketplace-écho.yaml"
marketplace_yaml.write_text(
"""
name: écho
label: "Écho café"
description: "localized marketplace entry"
tags: "démo, marché"
transport: stdio
command: python
args: ["-m", "écho"]
""".lstrip(),
encoding="utf-8",
)
entry = registry.parse_marketplace_yaml(marketplace_yaml)
assert entry.name == "écho"
assert entry.tags == ["démo", "marché"]
venv = tmp_path / "uv" / "tools" / "evoscientist"
venv.mkdir(parents=True)
(venv / "uv-receipt.toml").write_text(
"""
[tool]
requirements = [
{ name = "evoscientist" },
{ name = "mcp-écho", specifier = ">=1.0" },
]
""".lstrip(),
encoding="utf-8",
)
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
assert registry._uv_tool_existing_requirements()["mcp-écho"] == "mcp-écho>=1.0"
runtime = manager.LanggraphRuntimePaths.for_directory(tmp_path / "runtime")
monkeypatch.setattr(manager, "RUNTIME", runtime)
workspace = tmp_path / "workspace-écho"
manager._write_workspace_sidecar(workspace, 12345)
sidecar = manager._read_workspace_sidecar()
assert sidecar is not None
assert sidecar["workspace"] == str(workspace)
assert sidecar["pid"] == 12345
runtime.pid_file.write_text(str(os.getpid()), encoding="utf-8")
class _NotLanggraphProcess:
def __init__(self, pid):
self.pid = pid
def cmdline(self):
return ["python", "localized-test"]
def kill(self):
raise AssertionError("non-langgraph pid must not be killed")
monkeypatch.setattr(manager, "_list_pids_on_port", lambda port: [os.getpid()])
monkeypatch.setattr(manager.psutil, "Process", _NotLanggraphProcess)
assert manager._kill_owned_stale_process(6174) is False
assert not runtime.pid_file.exists()
assert not runtime.workspace_sidecar.exists()