686 lines
28 KiB
Python
686 lines
28 KiB
Python
"""覆盖率 85% 第 4 步第 6 批:host.py 纯函数与小方法长尾。
|
|
|
|
只动测试。全部是直接调用生产函数/方法:用量汇总、链路
|
|
摘要、等待轮询、状态锁与恢复、git diff/合并/验测、PR 解析
|
|
与正文、通知、doctor、Server 的 trace/close(不拉起真实
|
|
app-server 进程)。
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import queue
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import host
|
|
|
|
|
|
def _git(repo, *args, **kw):
|
|
return subprocess.run(["git", "-C", repo] + list(args),
|
|
capture_output=True, text=True, **kw)
|
|
|
|
|
|
def _make_repo(base):
|
|
repo = os.path.join(base, "repo")
|
|
os.makedirs(repo)
|
|
_git(repo, "init", "-q", "-b", "main")
|
|
_git(repo, "config", "user.email", "t@t.t")
|
|
_git(repo, "config", "user.name", "t")
|
|
with open(os.path.join(repo, "a.py"), "w") as f:
|
|
f.write("x = 1\n")
|
|
_git(repo, "add", ".")
|
|
_git(repo, "commit", "-q", "-m", "init")
|
|
return repo
|
|
|
|
|
|
class UsageAndEventsTest(unittest.TestCase):
|
|
def test_sum_turn_usage(self):
|
|
events = [
|
|
{"method": "other", "params": {}},
|
|
{"method": "thread/tokenUsage/updated",
|
|
"params": {"threadId": "th-1", "turnId": "tu-1",
|
|
"tokenUsage": {"last": {
|
|
"inputTokens": 10, "outputTokens": 5,
|
|
"cachedInputTokens": 2,
|
|
"totalTokens": 15}}}},
|
|
{"method": "thread/tokenUsage/updated",
|
|
"params": {"threadId": "th-1", "turnId": "tu-2",
|
|
"tokenUsage": {"last": {
|
|
"inputTokens": 7, "outputTokens": 3}}}},
|
|
{"method": "thread/tokenUsage/updated",
|
|
"params": {"threadId": "th-2", "turnId": "tu-1",
|
|
"tokenUsage": {"last": {"inputTokens": 99}}}},
|
|
]
|
|
total = host._sum_turn_usage(events, "th-1", "tu-1")
|
|
self.assertEqual(total["input_tokens"], 10)
|
|
self.assertEqual(total["output_tokens"], 5)
|
|
self.assertEqual(total["cached_input_tokens"], 2)
|
|
self.assertEqual(total["completions"], 1)
|
|
total = host._sum_turn_usage(events, "th-1", None)
|
|
self.assertEqual(total["input_tokens"], 17)
|
|
self.assertEqual(total["completions"], 2)
|
|
self.assertIsNone(host._sum_turn_usage(events, "th-9", None))
|
|
self.assertIsNone(host._sum_turn_usage([], "th-1", None))
|
|
self.assertIsNone(host._sum_turn_usage(None, "th-1", None))
|
|
|
|
def test_partial_text_from_events(self):
|
|
events = [
|
|
{"params": {"item": {"type": "agentMessage",
|
|
"text": "first"}}},
|
|
{"params": {"item": {"type": "agentMessage",
|
|
"text": "first"}}},
|
|
{"params": {"item": {"type": "delta", "text": "skip"}}},
|
|
{"params": {"item": {"type": "agentMessage",
|
|
"text": "second"}}},
|
|
{"params": {}},
|
|
]
|
|
self.assertEqual(host._partial_text_from_events(events),
|
|
"first\nsecond")
|
|
self.assertEqual(host._partial_text_from_events([]), "")
|
|
self.assertEqual(host._partial_text_from_events(None), "")
|
|
|
|
def test_add_usage(self):
|
|
self.assertIsNone(host._add_usage(None, None))
|
|
self.assertEqual(host._add_usage(None, {"a": 1}), {"a": 1})
|
|
self.assertEqual(host._add_usage({"a": 1}, None), {"a": 1})
|
|
out = host._add_usage({"a": 1, "s": "x"}, {"a": 2, "b": 3})
|
|
self.assertEqual(out, {"a": 3, "s": "x", "b": 3})
|
|
|
|
def test_interrupt_request_path_sanitizes(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
try:
|
|
p = host._interrupt_request_path(
|
|
os.path.join(tmp.name, "host-state.json"), "a/b c")
|
|
self.assertEqual(os.path.basename(p), "a-b-c.json")
|
|
self.assertEqual(os.path.basename(os.path.dirname(p)),
|
|
"interrupt-requests")
|
|
finally:
|
|
tmp.cleanup()
|
|
|
|
|
|
class WaitTurnTest(unittest.TestCase):
|
|
def test_completed_immediately(self):
|
|
q = queue.Queue()
|
|
q.put({"done": True})
|
|
status, done = host._wait_turn(q, timeout=5)
|
|
self.assertEqual((status, done), ("completed", {"done": True}))
|
|
|
|
def test_timeout_and_progress(self):
|
|
q = queue.Queue()
|
|
seen = []
|
|
status, done = host._wait_turn(
|
|
q, timeout=0.35, progress_interval=0.1,
|
|
on_progress=lambda e: seen.append(round(e, 1)))
|
|
self.assertEqual(status, "timeout")
|
|
self.assertIsNone(done)
|
|
self.assertGreaterEqual(len(seen), 2)
|
|
|
|
def test_interrupt_requested_and_swallow(self):
|
|
q = queue.Queue()
|
|
calls = {"n": 0}
|
|
|
|
def _check():
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise RuntimeError("check 炸了")
|
|
return True
|
|
status, _ = host._wait_turn(q, timeout=5,
|
|
interrupt_check=_check)
|
|
self.assertEqual(status, "interrupted-requested")
|
|
# on_progress 抛错也被吞掉,不影响 timeout 判定。
|
|
status, _ = host._wait_turn(
|
|
q, timeout=0.25, progress_interval=0.1,
|
|
on_progress=lambda e: (_ for _ in ()).throw(
|
|
RuntimeError("progress 炸了")))
|
|
self.assertEqual(status, "timeout")
|
|
|
|
def test_unlimited_waits_for_done(self):
|
|
q = queue.Queue()
|
|
|
|
def _put():
|
|
time.sleep(0.2)
|
|
q.put("x")
|
|
import threading
|
|
threading.Thread(target=_put, daemon=True).start()
|
|
status, done = host._wait_turn(q, timeout=0)
|
|
self.assertEqual((status, done), ("completed", "x"))
|
|
|
|
|
|
class SummarizeTraceTest(unittest.TestCase):
|
|
def _params(self, item, **kw):
|
|
p = {"item": item}
|
|
p.update(kw)
|
|
return p
|
|
|
|
def test_all_item_types(self):
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "commandExecution", "id": "c1",
|
|
"command": "ls", "cwd": "/tmp",
|
|
"exitCode": 0, "durationMs": 5},
|
|
completedAtMs=123, threadId="th-1"))
|
|
self.assertEqual(out[0]["kind"], "exec")
|
|
self.assertEqual(out[0]["ts"], 123)
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "fileChange", "id": "f1",
|
|
"status": "done",
|
|
"changes": [{"path": "a.py", "kind": "edit"},
|
|
"bad",
|
|
{"path": "b.py"}]},
|
|
thread={"id": "th-9"}))
|
|
self.assertEqual([e["id"] for e in out],
|
|
["f1:a.py", "f1:b.py"])
|
|
self.assertEqual(out[0]["thread"], "th-9")
|
|
long_text = "z" * 400
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "agentMessage", "id": "m1",
|
|
"text": long_text}))
|
|
self.assertEqual(len(out[0]["text"]), 300)
|
|
self.assertTrue(out[0]["truncated"])
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "reasoning", "id": "r1",
|
|
"summary": ["a", 5, "b"]}))
|
|
self.assertEqual(out[0]["summary"], "a b")
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "mcpToolCall", "id": "t1",
|
|
"server": "s", "tool": "t",
|
|
"status": "ok"}))
|
|
self.assertEqual(out[0]["kind"], "tool")
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "functionCallOutput", "id": "o1",
|
|
"name": "f"}))
|
|
self.assertEqual(out[0]["kind"], "call")
|
|
out = host._summarize_trace_item(
|
|
"m", self._params({"type": "plan", "id": "p1",
|
|
"text": "plan text"}))
|
|
self.assertEqual(out[0]["kind"], "plan")
|
|
|
|
def test_empty_and_unknown(self):
|
|
self.assertEqual(host._summarize_trace_item("m", {}), [])
|
|
self.assertEqual(host._summarize_trace_item(
|
|
"m", {"item": "not-a-dict"}), [])
|
|
self.assertEqual(host._summarize_trace_item(
|
|
"m", {"item": {"type": "userMessage", "text": "hi"}}), [])
|
|
self.assertEqual(host._summarize_trace_item(
|
|
"m", {"item": {"type": "agentMessage", "text": ""}}), [])
|
|
self.assertEqual(host._summarize_trace_item(
|
|
"m", {"item": {"type": "reasoning", "summary": []}}), [])
|
|
self.assertEqual(host._summarize_trace_item(
|
|
"m", {"item": {"type": "plan", "text": ""}}), [])
|
|
|
|
def test_trace_path_and_marker(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
try:
|
|
state_path = os.path.join(tmp.name, "host-state.json")
|
|
path = host._trace_file_path(state_path, "a/b")
|
|
self.assertEqual(os.path.basename(path), "a-b.jsonl")
|
|
host._append_trace_marker(state_path, "a/b",
|
|
{"kind": "mark"})
|
|
with open(path) as f:
|
|
row = json.loads(f.readline())
|
|
self.assertEqual(row["kind"], "mark")
|
|
finally:
|
|
tmp.cleanup()
|
|
|
|
|
|
class StateLockAndReadTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self.tmp.name, "host-state.json")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_tmp_path_unique(self):
|
|
a = host._state_tmp_path(self.path)
|
|
b = host._state_tmp_path(self.path)
|
|
self.assertNotEqual(a, b)
|
|
self.assertTrue(a.startswith(self.path + ".tmp."))
|
|
|
|
def test_lock_exclusive_and_shared(self):
|
|
with host._state_lock(self.path):
|
|
self.assertTrue(os.path.exists(self.path + ".lockdir"))
|
|
self.assertFalse(os.path.exists(self.path + ".lockdir"))
|
|
with host._state_lock(self.path, exclusive=False):
|
|
self.assertFalse(os.path.exists(self.path + ".lockdir"))
|
|
|
|
def test_lock_stale_removed_and_timeout(self):
|
|
lockdir = self.path + ".lockdir"
|
|
os.makedirs(lockdir)
|
|
old = time.time() - 10000
|
|
os.utime(lockdir, (old, old))
|
|
with host._state_lock(self.path):
|
|
pass
|
|
self.assertFalse(os.path.exists(lockdir))
|
|
os.makedirs(lockdir)
|
|
with mock.patch.object(host, "_STATE_LOCK_TIMEOUT", 0.01):
|
|
with self.assertRaises(RuntimeError):
|
|
with host._state_lock(self.path):
|
|
pass
|
|
os.rmdir(lockdir)
|
|
|
|
def test_read_state_file_variants(self):
|
|
self.assertEqual(host._read_state_file(self.path),
|
|
{"tasks": {}, "automations": []})
|
|
with open(self.path, "w") as f:
|
|
f.write("{bad")
|
|
self.assertEqual(host._read_state_file(self.path),
|
|
{"tasks": {}, "automations": []})
|
|
with self.assertRaises(RuntimeError):
|
|
host._read_state_file_for_save(self.path)
|
|
with open(self.path, "w") as f:
|
|
json.dump(["not", "a", "dict"], f)
|
|
with self.assertRaises(RuntimeError):
|
|
host._read_state_file_for_save(self.path)
|
|
with open(self.path, "w") as f:
|
|
json.dump({"tasks": [], "automations": []}, f)
|
|
with self.assertRaises(RuntimeError):
|
|
host._read_state_file_for_save(self.path)
|
|
with open(self.path, "w") as f:
|
|
json.dump({"tasks": {}, "automations": {}}, f)
|
|
with self.assertRaises(RuntimeError):
|
|
host._read_state_file_for_save(self.path)
|
|
with open(self.path, "w") as f:
|
|
json.dump({"tasks": {}, "automations": []}, f)
|
|
self.assertEqual(host._read_state_file_for_save(self.path),
|
|
{"tasks": {}, "automations": []})
|
|
|
|
def test_load_state_strict(self):
|
|
data, err = host.load_state_strict(self.path)
|
|
self.assertIsNone(err)
|
|
with open(self.path, "w") as f:
|
|
f.write("{bad")
|
|
data, err = host.load_state_strict(self.path)
|
|
self.assertIsNone(data)
|
|
self.assertIsNotNone(err)
|
|
|
|
|
|
class RestoreAndRecoverTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self.tmp.name, "host-state.json")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _write(self, path, obj):
|
|
with open(path, "w") as f:
|
|
json.dump(obj, f)
|
|
|
|
def test_restore_good_is_noop(self):
|
|
self._write(self.path, {"tasks": {}, "automations": [],
|
|
"version": 5})
|
|
ok, msg = host.restore_state(self.path)
|
|
self.assertTrue(ok)
|
|
self.assertIn("完好", msg)
|
|
|
|
def test_restore_from_bak1_when_bak_bad(self):
|
|
with open(self.path, "w") as f:
|
|
f.write("{bad")
|
|
with open(self.path + ".bak", "w") as f:
|
|
f.write("{also bad")
|
|
self._write(self.path + ".bak.1",
|
|
{"tasks": {}, "automations": [], "version": 7})
|
|
ok, msg = host.restore_state(self.path)
|
|
self.assertTrue(ok)
|
|
self.assertIn(".bak.1", msg)
|
|
self.assertEqual(host.load_state(self.path)["version"], 7)
|
|
leftovers = [n for n in os.listdir(self.tmp.name)
|
|
if n.startswith("host-state.json.corrupt.")]
|
|
self.assertEqual(len(leftovers), 1)
|
|
|
|
def test_restore_no_backup(self):
|
|
with open(self.path, "w") as f:
|
|
f.write("{bad")
|
|
ok, msg = host.restore_state(self.path)
|
|
self.assertFalse(ok)
|
|
self.assertIn("没有可用的备份", msg)
|
|
|
|
def test_recover_marks_only_old_running(self):
|
|
now = time.time()
|
|
state = {"tasks": {
|
|
"old": {"name": "old", "status": "running",
|
|
"created_at": now - 5000},
|
|
"fresh": {"name": "fresh", "status": "running",
|
|
"last_turn_at": now - 10},
|
|
"done": {"name": "done", "status": "completed",
|
|
"created_at": now - 5000}},
|
|
"automations": []}
|
|
got = host.recover_interrupted(state, self.path,
|
|
older_than=3600)
|
|
self.assertEqual(got, ["old"])
|
|
self.assertEqual(state["tasks"]["old"]["status"],
|
|
"interrupted")
|
|
self.assertEqual(state["tasks"]["fresh"]["status"],
|
|
"running")
|
|
self.assertEqual(state["tasks"]["done"]["status"],
|
|
"completed")
|
|
|
|
|
|
class GitHelpersTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.repo = _make_repo(self.tmp.name)
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_task_diff_tracked_and_untracked(self):
|
|
with open(os.path.join(self.repo, "a.py"), "a") as f:
|
|
f.write("y = 2\n")
|
|
with open(os.path.join(self.repo, "new.txt"), "w") as f:
|
|
f.write("hello\n")
|
|
os.makedirs(os.path.join(self.repo, "__pycache__"),
|
|
exist_ok=True)
|
|
with open(os.path.join(self.repo, "__pycache__",
|
|
"x.pyc"), "wb") as f:
|
|
f.write(b"\x00\x01")
|
|
out = host.task_diff(self.repo)
|
|
self.assertIn("y = 2", out["diff"])
|
|
self.assertIn("new.txt", out["untracked"])
|
|
self.assertEqual(out["untracked_contents"]["new.txt"],
|
|
"hello\n")
|
|
self.assertNotIn("__pycache__/x.pyc",
|
|
out["untracked_contents"])
|
|
|
|
def test_task_diff_non_git_dir(self):
|
|
plain = os.path.join(self.tmp.name, "plain")
|
|
os.makedirs(plain)
|
|
with open(os.path.join(plain, "f.txt"), "w") as f:
|
|
f.write("x")
|
|
out = host.task_diff(plain)
|
|
self.assertEqual(out["diff"], "")
|
|
self.assertIn("f.txt", out["untracked"])
|
|
|
|
def test_topo_sort_and_errors(self):
|
|
jobs = [{"name": "b", "depends_on": ["a"], "priority": 1},
|
|
{"name": "a", "priority": 5},
|
|
{"name": "c", "depends_on": ["a"], "priority": 9}]
|
|
ordered = [j["name"] for j in host.topo_sort_jobs(jobs)]
|
|
self.assertEqual(ordered, ["a", "c", "b"])
|
|
with self.assertRaises(ValueError):
|
|
host.topo_sort_jobs([{"name": "a",
|
|
"depends_on": ["ghost"]}])
|
|
with self.assertRaises(ValueError):
|
|
host.topo_sort_jobs([{"name": "a",
|
|
"depends_on": ["a"]}])
|
|
with self.assertRaises(ValueError):
|
|
host.topo_sort_jobs([{"name": "a",
|
|
"depends_on": ["b"]},
|
|
{"name": "b",
|
|
"depends_on": ["a"]}])
|
|
|
|
def test_detect_verify_and_run_verify(self):
|
|
self.assertIsNone(host.detect_verify_command(self.repo))
|
|
os.makedirs(os.path.join(self.repo, "tests"))
|
|
self.assertIn("unittest",
|
|
host.detect_verify_command(self.repo))
|
|
self.assertIsNone(host.detect_verify_command(None))
|
|
self.assertEqual(host.run_verify("", self.repo), (0, ""))
|
|
rc, out = host.run_verify("echo hi", self.repo)
|
|
self.assertEqual((rc, out), (0, "hi"))
|
|
rc, _ = host.run_verify("false", self.repo)
|
|
self.assertNotEqual(rc, 0)
|
|
|
|
def test_merge_task_success_and_verify_fail(self):
|
|
wt = os.path.join(self.tmp.name, "wt1")
|
|
_git(self.repo, "worktree", "add", wt, "-b", "host/t1")
|
|
with open(os.path.join(wt, "a.py"), "a") as f:
|
|
f.write("z = 3\n")
|
|
ok, msg = host.merge_task(self.repo, wt, "host/t1", "t1",
|
|
verify_command="true")
|
|
self.assertTrue(ok, msg)
|
|
self.assertFalse(os.path.exists(wt))
|
|
self.assertNotIn("host/t1",
|
|
_git(self.repo, "branch").stdout)
|
|
with open(os.path.join(self.repo, "a.py")) as f:
|
|
self.assertIn("z = 3", f.read())
|
|
# 验测失败:回滚主仓、分支与 worktree 保留。
|
|
wt2 = os.path.join(self.tmp.name, "wt2")
|
|
_git(self.repo, "worktree", "add", wt2, "-b", "host/t2")
|
|
with open(os.path.join(wt2, "a.py"), "a") as f:
|
|
f.write("w = 4\n")
|
|
ok, msg = host.merge_task(self.repo, wt2, "host/t2", "t2",
|
|
verify_command="false")
|
|
self.assertFalse(ok)
|
|
self.assertIn("验测失败", msg)
|
|
with open(os.path.join(self.repo, "a.py")) as f:
|
|
self.assertNotIn("w = 4", f.read())
|
|
self.assertIn("host/t2", _git(self.repo, "branch").stdout)
|
|
_git(self.repo, "worktree", "remove", "--force", wt2)
|
|
_git(self.repo, "branch", "-D", "host/t2")
|
|
|
|
|
|
class BudgetAndNotifyTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self.tmp.name, "host-state.json")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_record_usage_and_budget(self):
|
|
state = {}
|
|
u = host.record_turn_usage(state)
|
|
self.assertEqual(u["turns"], 1)
|
|
u = host.record_turn_usage(state, failed=True)
|
|
self.assertEqual((u["turns"], u["failed_turns"]), (2, 1))
|
|
# 跨天重置。
|
|
state["usage"] = {"date": "2000-01-01", "turns": 9,
|
|
"failed_turns": 9}
|
|
u = host.record_turn_usage(state)
|
|
self.assertEqual((u["turns"], u["failed_turns"]), (1, 0))
|
|
today = time.strftime("%Y-%m-%d", time.localtime())
|
|
self.assertFalse(host.budget_exceeded(
|
|
{"usage": {"date": today, "turns": 1}}, {}))
|
|
self.assertFalse(host.budget_exceeded(
|
|
{"usage": {"date": today, "turns": 99}},
|
|
{"max_daily_turns": "bogus"}))
|
|
self.assertFalse(host.budget_exceeded(
|
|
{"usage": {"date": "2000-01-01", "turns": 99}},
|
|
{"max_daily_turns": 5}))
|
|
self.assertTrue(host.budget_exceeded(
|
|
{"usage": {"date": today, "turns": 5}},
|
|
{"max_daily_turns": 5}))
|
|
|
|
def test_notify_failure_log_only_and_command(self):
|
|
msg = host.notify_failure(self.path, {}, "t1",
|
|
{"status": "failed", "text": "boom"})
|
|
self.assertIn("FAILED t1", msg)
|
|
log_path = os.path.join(self.tmp.name, "host-run.log")
|
|
self.assertTrue(os.path.exists(log_path))
|
|
out_file = os.path.join(self.tmp.name, "notified.txt")
|
|
cfg = {"notify_command":
|
|
"echo {task} {status} > " + out_file}
|
|
host.notify_failure(self.path, cfg, "t1",
|
|
{"status": "failed", "text": "boom"})
|
|
with open(out_file) as f:
|
|
self.assertIn("t1", f.read())
|
|
# 坏模板:记 template error,不跑命令。
|
|
before = open(log_path).read()
|
|
host.notify_failure(self.path, {"notify_command": "echo {nope}"},
|
|
"t1", {"status": "failed", "text": "x"})
|
|
after = open(log_path).read()
|
|
self.assertIn("template error", after[len(before):])
|
|
|
|
def test_build_fix_prompt_and_pr_body(self):
|
|
task = {"name": "t1", "prompt": "do it",
|
|
"review_comments": [
|
|
{"file": "a.py", "line": 3, "comment": "fix",
|
|
"status": "open"},
|
|
{"file": "b.py", "line": 1, "comment": "old",
|
|
"status": "resolved"}]}
|
|
prompt = host.build_fix_prompt(task)
|
|
self.assertIn("a.py:3: fix", prompt)
|
|
self.assertNotIn("old", prompt)
|
|
body = host.build_pr_body(task)
|
|
self.assertIn("host 任务:t1", body)
|
|
self.assertIn("a.py:3: fix", body)
|
|
body = host.build_pr_body({"name": "t2"})
|
|
self.assertIn("(无提示)", body)
|
|
self.assertIn("无未解决评论", body)
|
|
|
|
def test_check_state(self):
|
|
state = {"version": 5, "tasks": {
|
|
"bad": {"status": "failed", "cwd": "/nonexistent-x"},
|
|
"ok": {"status": "completed"},
|
|
"gone": {"status": "running", "cwd": "/nonexistent-x",
|
|
"review_comments": [
|
|
{"status": "open", "file": "a.py",
|
|
"line": 1, "comment": "c"}]}},
|
|
"automations": []}
|
|
out = host.check_state(self.path, state)
|
|
self.assertEqual(out["version"], 5)
|
|
self.assertFalse(out["backup_exists"])
|
|
self.assertEqual(out["failed_tasks"], ["bad"])
|
|
self.assertEqual(sorted(out["missing_worktrees"]),
|
|
["bad", "gone"])
|
|
self.assertEqual(out["open_review_comments"], 1)
|
|
|
|
|
|
class ParsePrChecksTest(unittest.TestCase):
|
|
def test_tab_and_human_formats(self):
|
|
checks, all_pass = host.parse_pr_checks(
|
|
"name\tstate\nbuild\tpass\nlint\tfail\n")
|
|
self.assertEqual(checks, [{"name": "build", "state": "pass"},
|
|
{"name": "lint", "state": "fail"}])
|
|
self.assertFalse(all_pass)
|
|
checks, all_pass = host.parse_pr_checks(
|
|
"✓ build pass\n✓ lint passing\n")
|
|
self.assertTrue(all_pass)
|
|
self.assertEqual([c["state"] for c in checks],
|
|
["pass", "pass"])
|
|
checks, all_pass = host.parse_pr_checks(
|
|
"no checks reported\n")
|
|
self.assertEqual((checks, all_pass), ([], False))
|
|
checks, all_pass = host.parse_pr_checks("")
|
|
self.assertEqual((checks, all_pass), ([], False))
|
|
checks, _ = host.parse_pr_checks("build queued\ntest skipped\n"
|
|
"job cancelled\nx neutral\n"
|
|
"y in_progress\nz failing\n")
|
|
self.assertEqual([c["state"] for c in checks],
|
|
["pending", "skipped", "cancelled",
|
|
"neutral", "pending", "fail"])
|
|
|
|
|
|
class CliHelpersTest(unittest.TestCase):
|
|
def test_load_config_and_effective_cfg(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
try:
|
|
self.assertEqual(host._load_config_file(None), {})
|
|
self.assertEqual(host._load_config_file(
|
|
os.path.join(tmp.name, "nope.json")), {})
|
|
bad = os.path.join(tmp.name, "bad.json")
|
|
with open(bad, "w") as f:
|
|
f.write("{bad")
|
|
with self.assertRaises(RuntimeError):
|
|
host._load_config_file(bad)
|
|
cfg = {"max_daily_turns": 5, "other": 1}
|
|
self.assertIs(host._effective_cfg(cfg, False), cfg)
|
|
out = host._effective_cfg(cfg, True)
|
|
self.assertIsNone(out["max_daily_turns"])
|
|
self.assertEqual(out["other"], 1)
|
|
self.assertEqual(cfg["max_daily_turns"], 5)
|
|
finally:
|
|
tmp.cleanup()
|
|
|
|
def test_resolve_path_and_thread_result(self):
|
|
self.assertEqual(host._resolve_path("/base", ""), "")
|
|
self.assertEqual(host._resolve_path("/base", "/abs"), "/abs")
|
|
self.assertEqual(host._resolve_path("/base", "rel"),
|
|
"/base/rel")
|
|
self.assertEqual(host._thread_cli_result({"result": {"a": 1}},
|
|
"thread-list"),
|
|
{"a": 1})
|
|
self.assertEqual(host._thread_cli_result({"result": None},
|
|
"thread-list"), {})
|
|
with self.assertRaises(RuntimeError):
|
|
host._thread_cli_result({"error": "boom"}, "thread-list")
|
|
with self.assertRaises(RuntimeError):
|
|
host._thread_cli_result(None, "thread-list")
|
|
|
|
def test_server_command(self):
|
|
cmd = host._server_command("/bin/codex")
|
|
self.assertEqual(cmd[-1], "app-server")
|
|
self.assertIn("-c", cmd)
|
|
with self.assertRaises(ValueError):
|
|
host._server_command("/bin/codex", provider="other")
|
|
with mock.patch.dict(os.environ, {"SUB2API_BASE_URL": ""}):
|
|
with self.assertRaises(RuntimeError):
|
|
host._server_command("/bin/codex",
|
|
provider="sub2api")
|
|
with mock.patch.dict(os.environ,
|
|
{"SUB2API_BASE_URL":
|
|
"https://gw.example.com/"}):
|
|
cmd = host._server_command("/bin/codex",
|
|
provider="sub2api")
|
|
self.assertTrue(any("gw.example.com/v1" in c
|
|
for c in cmd))
|
|
|
|
|
|
class ServerTraceCloseTest(unittest.TestCase):
|
|
def test_append_trace_and_close(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
try:
|
|
srv = host.Server(codex_bin="/bin/false")
|
|
state_path = os.path.join(tmp.name, "host-state.json")
|
|
srv.set_trace_file(state_path, "t1")
|
|
self.assertIsNotNone(srv.trace_path)
|
|
srv._append_trace({"method": "x", "params": {
|
|
"item": {"type": "agentMessage", "id": "m1",
|
|
"text": "hello"}}})
|
|
srv._append_trace({"method": "x", "params": {
|
|
"item": {"type": "userMessage", "text": "skip"}}})
|
|
with open(srv.trace_path) as f:
|
|
lines = f.readlines()
|
|
self.assertEqual(len(lines), 1)
|
|
self.assertIn("hello", lines[0])
|
|
# trace_path 为 None 时 _append_trace 是空操作。
|
|
srv.trace_path = None
|
|
srv._append_trace({"method": "x", "params": {
|
|
"item": {"type": "agentMessage", "id": "m2",
|
|
"text": "nope"}}})
|
|
finally:
|
|
tmp.cleanup()
|
|
|
|
def test_close_with_fake_proc(self):
|
|
class _Stdin:
|
|
def __init__(self):
|
|
self.closed = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
class _Proc:
|
|
def __init__(self, wait_raises=False):
|
|
self.stdin = _Stdin()
|
|
self.stdout = _Stdin()
|
|
self.wait_raises = wait_raises
|
|
self.killed = False
|
|
|
|
def wait(self, timeout=None):
|
|
if self.wait_raises:
|
|
raise RuntimeError("not dead")
|
|
return 0
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
srv = host.Server(codex_bin="/bin/false")
|
|
proc = _Proc()
|
|
srv.proc = proc
|
|
srv.close()
|
|
self.assertTrue(proc.stdin.closed)
|
|
self.assertFalse(proc.killed)
|
|
proc2 = _Proc(wait_raises=True)
|
|
srv.proc = proc2
|
|
srv.close()
|
|
self.assertTrue(proc2.killed)
|
|
self.assertTrue(proc2.stdout.closed)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|