All checks were successful
CI / test (push) Successful in 4m18s
T5 OpenClawClient subprocess args: docker exec cmd + token-last placement, cron add/rm/list params, JSON parse success + failure paths. T6 webhook: marked sent on 2xx, failure does not raise; xfail guards expose missing retry and missing dedupe (.scratch/v111-architecture-scan.md §6.2).
179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
"""OpenClaw client + alert webhook tests (Gitea issue #5 / P0).
|
|
|
|
T5 — OpenClawClient subprocess args (token placement, cron subcommand params, JSON parse).
|
|
T6 — Alert webhook send/skip/retry/dedupe semantics.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from sqlmodel import Session
|
|
|
|
from agenteval.intelligent_eval import openclaw_client as oc_mod
|
|
from agenteval.intelligent_eval.alerts import AlertHistoryDB, AlertManager
|
|
from agenteval.intelligent_eval.openclaw_client import OpenClawClient
|
|
from agenteval.storage.db import utc_now
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T5 — OpenClawClient subprocess args
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_process(stdout: bytes, stderr: bytes = b"", returncode: int = 0):
|
|
p = MagicMock()
|
|
p.communicate = AsyncMock(return_value=(stdout, stderr))
|
|
p.returncode = returncode
|
|
return p
|
|
|
|
|
|
def test_create_cron_subprocess_args(monkeypatch: pytest.MonkeyPatch):
|
|
"""create_cron builds the documented docker exec invocation.
|
|
|
|
Asserts: `docker exec <container> openclaw cron add --cron <expr> --name <n>
|
|
--message <m> --json [--description state:...] --token=<token>`.
|
|
Token is appended LAST (documented requirement).
|
|
"""
|
|
captured = {}
|
|
|
|
async def fake_exec(*cmd, **kwargs):
|
|
captured["cmd"] = list(cmd)
|
|
return _make_process(b'{"id":"cron-1"}')
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
|
|
|
client = OpenClawClient(gateway_token="tok-xyz")
|
|
asyncio.run(
|
|
client.create_cron(
|
|
name="n", schedule="* * * * *", skill="the-skill",
|
|
state={"k": "v"},
|
|
)
|
|
)
|
|
|
|
cmd = captured["cmd"]
|
|
# prefix
|
|
assert cmd[0] == "docker"
|
|
assert cmd[1:4] == ["exec", "openclaw-eval", "openclaw"]
|
|
# subcommand + add
|
|
assert "cron" in cmd and "add" in cmd
|
|
# key flags
|
|
assert any(s.startswith("--cron=") or cmd[cmd.index("--cron") + 1] == "* * * * *" for s in cmd)
|
|
# token is last
|
|
assert cmd[-1] == "--token=tok-xyz"
|
|
# --message holds the skill (current implementation quirk: message, not --skill)
|
|
assert any(s.startswith("--message=the-skill") for s in cmd)
|
|
# description carries state
|
|
assert any(s.startswith("--description=state:") for s in cmd)
|
|
|
|
|
|
def test_list_crons_parses_json(monkeypatch: pytest.MonkeyPatch):
|
|
async def fake_exec(*cmd, **kwargs):
|
|
payload = json.dumps([{"id": "a", "name": "n", "schedule": "* * * * *", "enabled": True}]).encode()
|
|
return _make_process(payload)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
|
crons = asyncio.run(OpenClawClient().list_crons())
|
|
assert [c.id for c in crons] == ["a"]
|
|
|
|
|
|
def test_list_crons_invalid_json_raises(monkeypatch: pytest.MonkeyPatch):
|
|
async def fake_exec(*cmd, **kwargs):
|
|
return _make_process(b"not json {")
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
|
with pytest.raises(RuntimeError, match="parse"):
|
|
asyncio.run(OpenClawClient().list_crons())
|
|
|
|
|
|
def test_delete_cron_command_uses_rm(monkeypatch: pytest.MonkeyPatch):
|
|
captured = {}
|
|
|
|
async def fake_exec(*cmd, **kwargs):
|
|
captured["cmd"] = list(cmd)
|
|
return _make_process(b"")
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
|
asyncio.run(OpenClawClient().delete_cron("cron-99"))
|
|
assert "cron" in captured["cmd"]
|
|
assert "rm" in captured["cmd"]
|
|
assert "cron-99" in captured["cmd"]
|
|
assert captured["cmd"][-1].startswith("--token=")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T6 — Alert webhook semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_alert(db_session: Session) -> AlertHistoryDB:
|
|
import uuid
|
|
a = AlertHistoryDB(
|
|
id=str(uuid.uuid4()),
|
|
alert_type="test", severity="warning", rule_name="r",
|
|
message="m", metric_value=1.0, threshold=0.5,
|
|
created_at=utc_now(),
|
|
)
|
|
db_session.add(a)
|
|
db_session.commit()
|
|
db_session.refresh(a)
|
|
return a
|
|
|
|
|
|
def test_webhook_marked_sent_on_2xx(db_session: Session):
|
|
alert = _make_alert(db_session)
|
|
fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock()
|
|
with patch("agenteval.intelligent_eval.alerts.httpx.post", return_value=fake_resp) as p:
|
|
AlertManager(db_session, webhook_url="http://x")._send_webhook(alert)
|
|
assert p.called
|
|
db_session.refresh(alert)
|
|
assert alert.webhook_sent is True
|
|
|
|
|
|
def test_webhook_failure_does_not_raise_or_mark_sent(db_session: Session):
|
|
alert = _make_alert(db_session)
|
|
with patch("agenteval.intelligent_eval.alerts.httpx.post", side_effect=RuntimeError("boom")):
|
|
# Must not raise.
|
|
AlertManager(db_session, webhook_url="http://x")._send_webhook(alert)
|
|
db_session.refresh(alert)
|
|
assert alert.webhook_sent is False
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason=(
|
|
"Known gap: webhook has no retry. _send_webhook swallows the first "
|
|
"failure and leaves webhook_sent=False; no follow-up attempt is made. "
|
|
"Tracked in .scratch/v111-architecture-scan.md."
|
|
),
|
|
strict=False,
|
|
)
|
|
def test_webhook_retries_on_failure(db_session: Session):
|
|
alert = _make_alert(db_session)
|
|
fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock()
|
|
with patch(
|
|
"agenteval.intelligent_eval.alerts.httpx.post",
|
|
side_effect=[RuntimeError("transient"), fake_resp],
|
|
) as p:
|
|
AlertManager(db_session, webhook_url="http://x")._send_webhook(alert)
|
|
assert p.call_count >= 2
|
|
db_session.refresh(alert)
|
|
assert alert.webhook_sent is True
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
reason=(
|
|
"Known gap: no dedupe — every check_alerts trigger re-sends webhook "
|
|
"for the same alert. Tracked in .scratch/v111-architecture-scan.md."
|
|
),
|
|
strict=False,
|
|
)
|
|
def test_webhook_dedupes_repeat_triggers(db_session: Session):
|
|
"""Calling _send_webhook twice on the same alert must POST at most once."""
|
|
alert = _make_alert(db_session)
|
|
fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock()
|
|
with patch("agenteval.intelligent_eval.alerts.httpx.post", return_value=fake_resp) as p:
|
|
mgr = AlertManager(db_session, webhook_url="http://x")
|
|
mgr._send_webhook(alert)
|
|
mgr._send_webhook(alert)
|
|
assert p.call_count == 1
|