code-to10 v4 coverage thickening: 219 new assertion tests (batches A/B1/C/D1-D6)
Baseline merge head e5ff26a: 28144 stmts, 2807 missing, exact 90.0263%, margin +7. After thickening (full run): 2350 missing, exact 91.6501%, margin +464. Full suite 3516 items: 3509 pass, 1 failure (pre-existing November date failure, untouched), 6 skipped. No production code changed.
This commit is contained in:
1 parent
773945d136
commit
12e72dab9b
9 files changed
+3597
No files matched your search
@@ -0,0 +1,502 @@
|
||||
"""Coverage batch v4-C: host.py state/lock/worktree/verify/notify/
|
||||
pr/dispatch/goal-loop branches.
|
||||
|
||||
Stubbing discipline: the function under test is never stubbed.
|
||||
Patched collaborators are: socket.gethostname / os.getmtime / os.rmdir /
|
||||
os.replace / log_event / save_state / build_verify_audit_report /
|
||||
subprocess.run / gh runner / FakeServer (all sinks or data providers),
|
||||
plus host.HERE (a path constant) and os.environ git identity vars for
|
||||
the real-git worktree tests. Every assertion reads a real return value,
|
||||
a real file, or real stdout.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
from host import (
|
||||
_audit_verify,
|
||||
_cmd_verify_audit,
|
||||
_seed_worktree,
|
||||
_state_lock,
|
||||
_state_tmp_path,
|
||||
build_verify_audit_report,
|
||||
copy_tree_excluding_git,
|
||||
dispatch,
|
||||
log_event,
|
||||
make_worktree,
|
||||
merge_pr,
|
||||
notify_failure,
|
||||
parse_pr_checks,
|
||||
recover_interrupted,
|
||||
restore_state,
|
||||
run_gated_verify,
|
||||
run_gh,
|
||||
run_goal_loop,
|
||||
run_health_check,
|
||||
run_task_turn,
|
||||
run_verify_argv,
|
||||
save_state,
|
||||
task_diff,
|
||||
)
|
||||
|
||||
GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
}
|
||||
|
||||
|
||||
def _git(args, cwd, env_extra=None):
|
||||
env = dict(os.environ)
|
||||
env.update(GIT_ENV)
|
||||
if env_extra:
|
||||
env.update(env_extra)
|
||||
return subprocess.run(["git"] + args, cwd=cwd, capture_output=True,
|
||||
text=True, env=env)
|
||||
|
||||
|
||||
def _init_repo(path):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
_git(["init", "-q"], path)
|
||||
with open(os.path.join(path, "f.txt"), "w") as fh:
|
||||
fh.write("base\n")
|
||||
_git(["add", "-A"], path)
|
||||
_git(["commit", "-qm", "init"], path)
|
||||
|
||||
|
||||
class StateLockTestCase(unittest.TestCase):
|
||||
def test_state_tmp_path_socket_failure(self):
|
||||
with mock.patch.object(socket, "gethostname",
|
||||
side_effect=OSError("no hostname")):
|
||||
out = _state_tmp_path("/tmp/state.json")
|
||||
self.assertIn("unknown", out)
|
||||
|
||||
def test_state_lock_getmtime_failure_timeout(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
os.mkdir(path + ".lockdir")
|
||||
with mock.patch.object(host, "_STATE_LOCK_TIMEOUT", 0), \
|
||||
mock.patch("os.path.getmtime",
|
||||
side_effect=OSError("gone")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
with _state_lock(path):
|
||||
pass
|
||||
|
||||
def test_state_lock_stale_rmdir_once_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
lockdir = path + ".lockdir"
|
||||
os.mkdir(lockdir)
|
||||
old = time.time() - 100000
|
||||
os.utime(lockdir, (old, old))
|
||||
real_rmdir = os.rmdir
|
||||
calls = []
|
||||
|
||||
def _flaky(p):
|
||||
calls.append(p)
|
||||
if len(calls) == 1:
|
||||
raise OSError("someone else")
|
||||
return real_rmdir(p)
|
||||
with mock.patch("os.rmdir", side_effect=_flaky):
|
||||
with _state_lock(path):
|
||||
pass
|
||||
self.assertFalse(os.path.exists(lockdir))
|
||||
|
||||
def test_state_lock_final_rmdir_failure_swallowed(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with _state_lock(path):
|
||||
os.rmdir(path + ".lockdir")
|
||||
self.assertFalse(os.path.exists(path + ".lockdir"))
|
||||
|
||||
|
||||
class RestoreRecoverSaveLogTestCase(unittest.TestCase):
|
||||
def test_restore_state_replace_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with open(path, "w") as fh:
|
||||
fh.write("{broken")
|
||||
with mock.patch("os.replace",
|
||||
side_effect=RuntimeError("rename down")):
|
||||
ok, msg = restore_state(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("改名留底失败", msg)
|
||||
|
||||
def test_restore_state_no_file_no_backups(self):
|
||||
# 1047 行(corrupt=None)只在“严格读失败、但复查时文件已
|
||||
# 不在”这一竞态下可达:单线程里文件缺失时严格读直接返回
|
||||
# 空 state、无需恢复;这里用 exists 的调用序列注入该竞态。
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with open(path, "w") as fh:
|
||||
fh.write("{broken")
|
||||
real_exists = os.path.exists
|
||||
calls = []
|
||||
|
||||
def _exists(p):
|
||||
calls.append(p)
|
||||
if len(calls) == 1:
|
||||
return True
|
||||
return False
|
||||
with mock.patch("os.path.exists", side_effect=_exists):
|
||||
ok, msg = restore_state(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("没有可用的备份", msg)
|
||||
|
||||
def test_recover_interrupted_log_failure(self):
|
||||
state = {"tasks": {"t": {"status": "running",
|
||||
"last_turn_at": 0, "created_at": 0}}}
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")):
|
||||
out = recover_interrupted(state, "/tmp/state.json")
|
||||
self.assertEqual(out, ["t"])
|
||||
self.assertEqual(state["tasks"]["t"]["status"], "interrupted")
|
||||
|
||||
def test_save_state_conflict_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with open(path, "w") as fh:
|
||||
json.dump({"version": 5, "tasks": {}}, fh)
|
||||
os.mkdir(os.path.join(d, "host-run.log"))
|
||||
state = {"tasks": {}, "_loaded_version": 1}
|
||||
save_state(path, state)
|
||||
with open(path) as fh:
|
||||
saved = json.load(fh)
|
||||
self.assertEqual(saved["version"], 6)
|
||||
|
||||
def test_save_state_last_writer_socket_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with mock.patch.object(socket, "gethostname",
|
||||
side_effect=OSError("no hostname")):
|
||||
save_state(path, {"tasks": {}})
|
||||
with open(path) as fh:
|
||||
saved = json.load(fh)
|
||||
self.assertEqual(saved["last_writer"], str(os.getpid()))
|
||||
|
||||
def test_log_event_failure_swallowed(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
blocker = os.path.join(d, "blocker")
|
||||
with open(blocker, "w") as fh:
|
||||
fh.write("x")
|
||||
log_event(os.path.join(blocker, "state.json"), "hi")
|
||||
|
||||
def test_health_check_pid_alive_and_close_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
with open(path, "w") as fh:
|
||||
json.dump({"version": 1, "tasks": {}}, fh)
|
||||
with open(os.path.join(d, "host-daemon.pid"), "w") as fh:
|
||||
fh.write(str(os.getpid()))
|
||||
server = mock.Mock()
|
||||
server.list_threads.return_value = {"data": []}
|
||||
server.close.side_effect = RuntimeError("close down")
|
||||
out = run_health_check(path, {}, sys.executable, d, "a",
|
||||
server_factory=lambda: server)
|
||||
self.assertTrue(out["checks"]["daemon_alive"])
|
||||
self.assertEqual(out["checks"]["daemon_pid"], os.getpid())
|
||||
self.assertTrue(out["checks"]["server_ok"])
|
||||
|
||||
|
||||
class WorktreeDiffTestCase(unittest.TestCase):
|
||||
def test_make_worktree_existing(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "HERE", d):
|
||||
base = os.path.join(d, "work", "host-worktrees")
|
||||
os.makedirs(os.path.join(base, "t1"))
|
||||
path, branch = make_worktree("/tmp/repo", "t1")
|
||||
self.assertEqual(path, os.path.join(base, "t1"))
|
||||
self.assertIsNone(branch)
|
||||
|
||||
def test_make_worktree_branch_fallback(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo = os.path.join(d, "repo")
|
||||
_init_repo(repo)
|
||||
_git(["branch", "host/t1"], repo)
|
||||
with mock.patch.object(host, "HERE", d):
|
||||
path, branch = make_worktree(repo, "t1")
|
||||
self.assertEqual(branch, "host/t1")
|
||||
self.assertTrue(os.path.isdir(path))
|
||||
|
||||
def test_make_worktree_both_fail(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo = os.path.join(d, "repo")
|
||||
os.makedirs(repo)
|
||||
_git(["init", "-q"], repo)
|
||||
with mock.patch.object(host, "HERE", d):
|
||||
with self.assertRaises(RuntimeError):
|
||||
make_worktree(repo, "t1")
|
||||
|
||||
def test_make_worktree_copy_non_git(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo = os.path.join(d, "repo")
|
||||
os.makedirs(repo)
|
||||
with open(os.path.join(repo, "a.txt"), "w") as fh:
|
||||
fh.write("hi")
|
||||
with mock.patch.object(host, "HERE", d):
|
||||
path, branch = make_worktree(repo, "t1")
|
||||
self.assertIsNone(branch)
|
||||
with open(os.path.join(path, "a.txt")) as fh:
|
||||
self.assertEqual(fh.read(), "hi")
|
||||
|
||||
def test_task_diff_unreadable(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
repo = os.path.join(d, "repo")
|
||||
_init_repo(repo)
|
||||
with open(os.path.join(repo, "bin.dat"), "wb") as fh:
|
||||
fh.write(b"\xff\xfe\x00abc")
|
||||
out = task_diff(repo)
|
||||
self.assertEqual(out["untracked_contents"]["bin.dat"],
|
||||
"<binary or unreadable>")
|
||||
|
||||
|
||||
class VerifyAuditTestCase(unittest.TestCase):
|
||||
def test_audit_verify_log_failure(self):
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")):
|
||||
_audit_verify("/tmp/state.json", "true", "argv", 0)
|
||||
|
||||
def test_run_verify_argv_empty(self):
|
||||
self.assertEqual(run_verify_argv([], "/tmp"), (0, ""))
|
||||
|
||||
def test_run_verify_argv_non_str(self):
|
||||
with self.assertRaises(TypeError):
|
||||
run_verify_argv(["echo", 123], "/tmp")
|
||||
|
||||
def test_run_gated_verify_blank(self):
|
||||
self.assertEqual(run_gated_verify(" ", "/tmp"), (0, ""))
|
||||
|
||||
def test_build_report_non_dict_task(self):
|
||||
report = build_verify_audit_report(
|
||||
{"tasks": {"bad": "str",
|
||||
"ok": {"verify_command": None}}})
|
||||
self.assertEqual([r["task"] for r in report], [])
|
||||
|
||||
def test_cmd_verify_audit_notes_and_simple(self):
|
||||
rows = [
|
||||
{"task": "n", "kind": "shell", "features": ["f"],
|
||||
"reason": "", "argv": None, "notes": ["note1"],
|
||||
"applied": False},
|
||||
{"task": "s", "kind": "simple", "features": [],
|
||||
"reason": "", "argv": ["true"], "notes": [],
|
||||
"applied": False},
|
||||
]
|
||||
args = SimpleNamespace(apply=False, json=False, state="st.json")
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(host, "build_verify_audit_report",
|
||||
return_value=rows), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
_cmd_verify_audit(args, {"tasks": {}})
|
||||
out = buf.getvalue()
|
||||
self.assertIn("notes=note1", out)
|
||||
self.assertIn("[可转 argv=['true']]", out)
|
||||
|
||||
def test_cmd_verify_audit_json(self):
|
||||
args = SimpleNamespace(apply=False, json=True, state="st.json")
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(host, "build_verify_audit_report",
|
||||
return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
_cmd_verify_audit(args, {"tasks": {}})
|
||||
parsed = json.loads(buf.getvalue())
|
||||
self.assertEqual(parsed["applied"], 0)
|
||||
self.assertEqual(parsed["tasks"], [])
|
||||
|
||||
|
||||
class NotifyTestCase(unittest.TestCase):
|
||||
def _state_path(self, d):
|
||||
return os.path.join(d, "state.json")
|
||||
|
||||
def test_notify_no_command_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")):
|
||||
msg = notify_failure(self._state_path(d), {}, "t1",
|
||||
{"status": "failed", "text": "boom"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
def test_notify_template_error_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")):
|
||||
msg = notify_failure(
|
||||
self._state_path(d), {"notify_command": "echo {unknown}"},
|
||||
"t1", {"status": "failed", "text": "boom"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
def test_notify_rc_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")):
|
||||
msg = notify_failure(
|
||||
self._state_path(d), {"notify_command": "true"},
|
||||
"t1", {"status": "failed", "text": "boom"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
def test_notify_timeout_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")), \
|
||||
mock.patch("subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
"cmd", 30)):
|
||||
msg = notify_failure(
|
||||
self._state_path(d), {"notify_command": "sleep 60"},
|
||||
"t1", {"status": "failed", "text": "boom"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
def test_notify_generic_error_log_failure(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with mock.patch.object(host, "log_event",
|
||||
side_effect=RuntimeError("log down")), \
|
||||
mock.patch("subprocess.run",
|
||||
side_effect=RuntimeError("spawn down")):
|
||||
msg = notify_failure(
|
||||
self._state_path(d), {"notify_command": "true"},
|
||||
"t1", {"status": "failed", "text": "boom"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
|
||||
class PrDispatchTurnTestCase(unittest.TestCase):
|
||||
def test_parse_pr_checks_branches(self):
|
||||
checks, all_pass = parse_pr_checks("foo\t\nrandom words\npass\n")
|
||||
self.assertEqual(checks, [{"name": "pass", "state": "pass"}])
|
||||
self.assertTrue(all_pass)
|
||||
|
||||
def test_merge_pr_gh_failure(self):
|
||||
def _runner(args):
|
||||
if args[1] == "checks":
|
||||
return SimpleNamespace(returncode=0, stdout="ci\tpass\n",
|
||||
stderr="")
|
||||
return SimpleNamespace(returncode=1, stdout="",
|
||||
stderr="merge failed")
|
||||
task = {"name": "t", "pr_number": 7, "pr_url": "u"}
|
||||
ok, msg = merge_pr(task, gh_runner=_runner, repo="/tmp")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("gh pr merge 失败", msg)
|
||||
|
||||
def test_run_gh_real_path_mocked(self):
|
||||
sentinel = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
with mock.patch("subprocess.run", return_value=sentinel) as run:
|
||||
out = run_gh(["pr", "checks", "1"], cwd="/tmp")
|
||||
self.assertIs(out, sentinel)
|
||||
run.assert_called_once()
|
||||
|
||||
def test_copy_tree_skips_pyc(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = os.path.join(d, "src")
|
||||
dst = os.path.join(d, "dst")
|
||||
os.makedirs(src)
|
||||
with open(os.path.join(src, "a.pyc"), "wb") as fh:
|
||||
fh.write(b"\x00")
|
||||
with open(os.path.join(src, "b.txt"), "w") as fh:
|
||||
fh.write("b")
|
||||
copy_tree_excluding_git(src, dst)
|
||||
self.assertFalse(os.path.exists(os.path.join(dst, "a.pyc")))
|
||||
self.assertTrue(os.path.exists(os.path.join(dst, "b.txt")))
|
||||
|
||||
def test_dispatch_relative_repo(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
rel = os.path.join(d, "relrepo")
|
||||
os.makedirs(rel)
|
||||
server = SimpleNamespace(
|
||||
create_thread=lambda cwd, **kw: "th1")
|
||||
state = {"tasks": {}}
|
||||
with mock.patch.object(host, "HERE", d):
|
||||
task = dispatch(server, state, "t1", "prompt", "relrepo",
|
||||
"m", "low", None, None, use_worktree=False)
|
||||
self.assertEqual(task["repo"], rel)
|
||||
self.assertEqual(task["cwd"], rel)
|
||||
|
||||
def test_run_task_turn_type_error_reraise(self):
|
||||
class _Server:
|
||||
def run_turn(self, *args, **kwargs):
|
||||
raise TypeError("boom")
|
||||
task = {"thread_id": "th", "sandbox": None, "turns": []}
|
||||
with self.assertRaises(TypeError):
|
||||
run_task_turn(_Server(), {"tasks": {}}, task, "p", "m", "low",
|
||||
interrupt_file="/tmp/if")
|
||||
|
||||
def test_seed_worktree_git_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
seed = os.path.join(d, "seed")
|
||||
work = os.path.join(d, "work")
|
||||
os.makedirs(os.path.join(seed, ".git"))
|
||||
os.makedirs(work)
|
||||
with open(os.path.join(seed, "f.txt"), "w") as fh:
|
||||
fh.write("f")
|
||||
_seed_worktree(work, seed)
|
||||
self.assertTrue(os.path.exists(os.path.join(work, "f.txt")))
|
||||
self.assertFalse(os.path.exists(os.path.join(work, ".git")))
|
||||
|
||||
|
||||
class FakeServer:
|
||||
def run_turn(self, *args, **kwargs):
|
||||
return {"status": "completed", "text": ""}
|
||||
|
||||
|
||||
class GoalLoopTestCase(unittest.TestCase):
|
||||
def _setup(self, d):
|
||||
state_path = os.path.join(d, "state.json")
|
||||
task = {"name": "g", "thread_id": "x", "cwd": d,
|
||||
"sandbox": None, "turns": []}
|
||||
state = {"tasks": {"g": task}, "automations": []}
|
||||
return state_path, task, state
|
||||
|
||||
def test_goal_verify_oserror_interrupt(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
state_path, task, state = self._setup(d)
|
||||
out = run_goal_loop(
|
||||
FakeServer(), state, state_path, task, "goal", None, 1,
|
||||
"m", "low", "workspace-write", cfg={},
|
||||
verify_argv=["/nonexistent/binary-xyz"])
|
||||
self.assertTrue(out["interrupted"])
|
||||
self.assertEqual(task["status"], "interrupted")
|
||||
|
||||
def test_goal_save_failure_interrupt(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
state_path, task, state = self._setup(d)
|
||||
orig = host.save_state
|
||||
calls = []
|
||||
|
||||
def _flaky(path, st):
|
||||
calls.append(1)
|
||||
if len(calls) > 1:
|
||||
raise RuntimeError("save down")
|
||||
return orig(path, st)
|
||||
with mock.patch.object(host, "save_state",
|
||||
side_effect=_flaky):
|
||||
out = run_goal_loop(
|
||||
FakeServer(), state, state_path, task, "goal", "true",
|
||||
1, "m", "low", "workspace-write", cfg={})
|
||||
self.assertTrue(out["interrupted"])
|
||||
self.assertEqual(task["status"], "interrupted")
|
||||
|
||||
def test_goal_steps_write_failure_swallowed(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
state_path, task, state = self._setup(d)
|
||||
with open(os.path.join(d, "host-steps"), "w") as fh:
|
||||
fh.write("blocker")
|
||||
out = run_goal_loop(
|
||||
FakeServer(), state, state_path, task, "goal", "true",
|
||||
1, "m", "low", "workspace-write", cfg={})
|
||||
self.assertTrue(out["ok"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Coverage batch v4-D3: host.py wait/bin/client/run-turn/restore/
|
||||
merge/notify/pr/dispatch/main branches.
|
||||
|
||||
Stubbing discipline: the function under test is never stubbed. Patched
|
||||
collaborators are: _summarize_trace_item / _append_trace / request /
|
||||
_wait_turn / interrupt_turn / resume_thread / ensure_alive / _start /
|
||||
git / run_gh / _github_remote_url / task_diff / open_review_comments /
|
||||
merge_task / _cmd_verify_audit / daemonize / Server / subprocess.run /
|
||||
os.remove (sinks, data providers and predicates). Every assertion reads
|
||||
a real return value, a real file, or real stdout.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
from host import (
|
||||
Server,
|
||||
_wait_turn,
|
||||
create_pr,
|
||||
default_codex_bin,
|
||||
dispatch_from_branch,
|
||||
merge_task,
|
||||
notify_failure,
|
||||
parse_pr_checks,
|
||||
restore_state,
|
||||
)
|
||||
|
||||
GIT_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
}
|
||||
|
||||
|
||||
def _server():
|
||||
srv = object.__new__(Server)
|
||||
srv.proc = None
|
||||
srv.codex_bin = "codex"
|
||||
srv.codex_home = None
|
||||
srv.approval = "deny"
|
||||
srv.model_provider = None
|
||||
srv.progress_interval = 30
|
||||
srv.send_lock = threading.Lock()
|
||||
srv.next_id = 1
|
||||
srv.pending = {}
|
||||
srv.pending_lock = threading.Lock()
|
||||
srv.turn_queues = {}
|
||||
srv.events = []
|
||||
srv.events_lock = threading.Lock()
|
||||
srv.restart_count = 0
|
||||
srv.trace_path = None
|
||||
srv._trace_lines = 0
|
||||
return srv
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.closed = False
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def resume_thread(self, thread_id):
|
||||
pass
|
||||
|
||||
def interrupt_turn(self, thread_id, turn_id):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class WaitAndBinTestCase(unittest.TestCase):
|
||||
def test_wait_turn_tiny_timeout(self):
|
||||
# 154: remaining <= 0 on the first slice.
|
||||
q = queue.Queue()
|
||||
self.assertEqual(_wait_turn(q, 1e-5), ("timeout", None))
|
||||
|
||||
def test_default_codex_bin_paired(self):
|
||||
# 204
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(tmp, "bin"))
|
||||
paired = os.path.join(tmp, "bin", "codex")
|
||||
with open(paired, "w", encoding="utf-8") as f:
|
||||
f.write("x")
|
||||
with mock.patch.object(host, "HERE", tmp):
|
||||
with mock.patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("CODEX_BIN", None)
|
||||
self.assertEqual(default_codex_bin(), paired)
|
||||
|
||||
|
||||
class ServerClientTestCase(unittest.TestCase):
|
||||
def test_ensure_alive_true_and_false(self):
|
||||
# 456, 462-463
|
||||
srv = _server()
|
||||
srv.proc = mock.Mock()
|
||||
srv.proc.poll.return_value = None
|
||||
self.assertTrue(srv.ensure_alive())
|
||||
srv2 = _server()
|
||||
srv2.proc = None
|
||||
srv2._start = mock.Mock(side_effect=RuntimeError("start down"))
|
||||
self.assertFalse(srv2.ensure_alive())
|
||||
|
||||
def test_read_loop_non_dict_stderr_failure(self):
|
||||
# 501-502
|
||||
srv = _server()
|
||||
srv.proc = SimpleNamespace(stdout=[b"123\n"])
|
||||
bad_err = mock.Mock()
|
||||
bad_err.write.side_effect = RuntimeError("stderr down")
|
||||
with mock.patch.object(sys, "stderr", bad_err):
|
||||
srv._read_loop()
|
||||
bad_err.write.assert_called()
|
||||
|
||||
def test_read_loop_append_trace_failure(self):
|
||||
# 562-565
|
||||
srv = _server()
|
||||
srv.trace_path = os.path.join(
|
||||
tempfile.mkdtemp(), "trace.jsonl")
|
||||
srv.proc = SimpleNamespace(stdout=[
|
||||
b'{"jsonrpc":"2.0","method":"agent/message","params":{}}\n'])
|
||||
srv._append_trace = mock.Mock(
|
||||
side_effect=RuntimeError("trace down"))
|
||||
srv._read_loop()
|
||||
self.assertEqual(len(srv.events), 1)
|
||||
|
||||
def test_read_loop_events_append_failure(self):
|
||||
# 570-571: outer except of the read loop.
|
||||
srv = _server()
|
||||
srv.proc = SimpleNamespace(stdout=[
|
||||
b'{"jsonrpc":"2.0","method":"agent/message","params":{}}\n'])
|
||||
srv.events = mock.Mock()
|
||||
srv.events.append.side_effect = RuntimeError("events down")
|
||||
srv._read_loop()
|
||||
|
||||
def test_request_broken_pipe_retry_then_raise(self):
|
||||
# 596-600
|
||||
srv = _server()
|
||||
srv.proc = SimpleNamespace(
|
||||
stdin=mock.Mock())
|
||||
srv.proc.stdin.write.side_effect = BrokenPipeError("pipe down")
|
||||
srv.ensure_alive = mock.Mock(return_value=True)
|
||||
with self.assertRaises(BrokenPipeError):
|
||||
srv.request("initialize", {}, timeout=60)
|
||||
self.assertEqual(srv.ensure_alive.call_count, 1)
|
||||
|
||||
def test_thread_method_error_branches(self):
|
||||
# 627, 639, 651, 662, 673
|
||||
srv = _server()
|
||||
srv.request = mock.Mock(return_value={"error": "boom"})
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.create_thread("/tmp")
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.list_threads()
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.resume_thread("th1")
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.start_review("th1", "target")
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.fork_thread("th1")
|
||||
with self.assertRaises(RuntimeError):
|
||||
srv.archive_thread("th1")
|
||||
|
||||
def test_fork_thread_non_dict_result_none(self):
|
||||
# 668
|
||||
srv = _server()
|
||||
srv.request = mock.Mock(return_value={"result": "not-a-dict"})
|
||||
self.assertIsNone(srv.fork_thread("th1"))
|
||||
|
||||
def test_append_trace_break_at_cap(self):
|
||||
# 430
|
||||
srv = _server()
|
||||
tmp = tempfile.mkdtemp()
|
||||
srv.trace_path = os.path.join(tmp, "trace.jsonl")
|
||||
srv._trace_lines = host.TRACE_MAX_LINES - 1
|
||||
with mock.patch.object(
|
||||
host, "_summarize_trace_item",
|
||||
return_value=[{"a": 1}, {"b": 2}]):
|
||||
srv._append_trace({"method": "x", "params": {}})
|
||||
with open(srv.trace_path, encoding="utf-8") as f:
|
||||
self.assertEqual(len(f.readlines()), 1)
|
||||
|
||||
def test_turn_error_empty_message_none(self):
|
||||
# 891
|
||||
srv = _server()
|
||||
home = tempfile.mkdtemp()
|
||||
srv.codex_home = home
|
||||
db_path = os.path.join(home, "thread_history_1.sqlite")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE thread_turns(thread_id TEXT, turn_id TEXT,"
|
||||
" error_json TEXT)")
|
||||
conn.execute(
|
||||
"INSERT INTO thread_turns VALUES(?, ?, ?)",
|
||||
("th1", "turn1", '{"message": ""}'))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertIsNone(srv._turn_error_from_history("th1", "turn1"))
|
||||
|
||||
def test_close_stdin_stdout_failures(self):
|
||||
# 899-900, 907-908
|
||||
srv = _server()
|
||||
proc = mock.Mock()
|
||||
proc.stdin.close.side_effect = RuntimeError("stdin down")
|
||||
proc.wait.return_value = 0
|
||||
proc.stdout.close.side_effect = RuntimeError("stdout down")
|
||||
srv.proc = proc
|
||||
srv.close()
|
||||
|
||||
|
||||
class RunTurnTestCase(unittest.TestCase):
|
||||
def _client(self):
|
||||
srv = _server()
|
||||
srv.request = mock.Mock(return_value={
|
||||
"result": {"turn": {"id": "turn1"}}})
|
||||
return srv
|
||||
|
||||
def test_resume_failure_swallowed(self):
|
||||
# 683-687
|
||||
srv = self._client()
|
||||
srv.proc = None
|
||||
srv.ensure_alive = mock.Mock(return_value=True)
|
||||
srv.resume_thread = mock.Mock(
|
||||
side_effect=RuntimeError("resume down"))
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
return_value=("completed", {
|
||||
"params": {"turn": {"items": []}}})):
|
||||
out = srv.run_turn("th1", "do work")
|
||||
self.assertEqual(out["status"], "completed")
|
||||
|
||||
def test_progress_success_and_failure(self):
|
||||
# 735-753
|
||||
srv = self._client()
|
||||
tmp = tempfile.mkdtemp()
|
||||
srv.trace_path = os.path.join(tmp, "trace.jsonl")
|
||||
srv._trace_lines = 0
|
||||
|
||||
def _fake_wait(q, timeout, progress_interval=None,
|
||||
on_progress=None, interrupt_check=None):
|
||||
on_progress(5)
|
||||
srv.trace_path = "/nonexistent-dir-xyz/trace.jsonl"
|
||||
on_progress(6)
|
||||
return ("completed", {"params": {"turn": {"items": []}}})
|
||||
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
side_effect=_fake_wait):
|
||||
out = srv.run_turn("th1", "do work")
|
||||
self.assertEqual(out["status"], "completed")
|
||||
with open(os.path.join(tmp, "trace.jsonl"),
|
||||
encoding="utf-8") as f:
|
||||
self.assertEqual(len(f.readlines()), 1)
|
||||
|
||||
def test_interrupted_error_and_remove_failure(self):
|
||||
# 773-780, 784-785
|
||||
srv = self._client()
|
||||
srv.interrupt_turn = mock.Mock(
|
||||
return_value={"error": "nope"})
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
interrupt_file = os.path.join(tmp, "req.json")
|
||||
with open(interrupt_file, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
return_value=("interrupted-requested",
|
||||
None)):
|
||||
with mock.patch("os.remove",
|
||||
side_effect=OSError("remove down")):
|
||||
out = srv.run_turn("th1", "do work",
|
||||
interrupt_file=interrupt_file)
|
||||
self.assertEqual(out["status"], "interrupted")
|
||||
|
||||
def test_wait_raises_empty_timeout(self):
|
||||
# 799-802
|
||||
srv = self._client()
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
side_effect=queue.Empty()):
|
||||
out = srv.run_turn("th1", "do work")
|
||||
self.assertEqual(out["status"], "timeout")
|
||||
|
||||
def test_fallback_final_answer_from_events(self):
|
||||
# 822
|
||||
srv = self._client()
|
||||
|
||||
def _fake_wait(q, timeout, progress_interval=None,
|
||||
on_progress=None, interrupt_check=None):
|
||||
srv.events.append({"params": {"item": {
|
||||
"type": "agentMessage", "phase": "final_answer",
|
||||
"text": "hello"}}})
|
||||
return ("completed", {"params": {"turn": {"items": []}}})
|
||||
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
side_effect=_fake_wait):
|
||||
out = srv.run_turn("th1", "do work")
|
||||
self.assertEqual(out["text"], "hello")
|
||||
|
||||
|
||||
class RestoreMergeNotifyTestCase(unittest.TestCase):
|
||||
def test_restore_corrupt_state_from_bak(self):
|
||||
# Corrupt main file -> renamed to .corrupt.* and restored from
|
||||
# .bak. (The missing-file else branch at 1046-1047 is
|
||||
# unreachable by construction: load_state_strict returns
|
||||
# (empty state, None) for a missing file, which takes the
|
||||
# early "state 完好" return; recorded in the gap ledger.)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
f.write("{not json")
|
||||
with open(state_path + ".bak", "w", encoding="utf-8") as f:
|
||||
json.dump({"version": 3, "tasks": {}}, f)
|
||||
ok, msg = restore_state(state_path)
|
||||
self.assertTrue(ok)
|
||||
self.assertIn(".bak", msg)
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["version"], 3)
|
||||
|
||||
def _git_result(self, returncode=0, stdout="", stderr=""):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout,
|
||||
stderr=stderr)
|
||||
|
||||
def test_merge_commit_failure(self):
|
||||
# 2019
|
||||
def _git(args, cwd):
|
||||
if args[0] == "commit":
|
||||
return self._git_result(1, "", "boom")
|
||||
if args[0] == "rev-parse":
|
||||
return self._git_result(0, "abc\n", "")
|
||||
return self._git_result(0, "", "")
|
||||
|
||||
with mock.patch.object(host, "git", side_effect=_git):
|
||||
ok, msg = merge_task("/tmp", "/tmp", "b", "t",
|
||||
verify_argv=[])
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("提交失败", msg)
|
||||
|
||||
def test_merge_retry_abort(self):
|
||||
# 2032-2033
|
||||
calls = []
|
||||
|
||||
def _git(args, cwd):
|
||||
calls.append(args)
|
||||
if args[0] == "rev-parse":
|
||||
return self._git_result(0, "abc\n", "")
|
||||
if args[0] == "merge" and "-X" not in args:
|
||||
return self._git_result(1, "", "conflict")
|
||||
if args[0] == "merge" and "-X" in args:
|
||||
return self._git_result(1, "", "still conflict")
|
||||
return self._git_result(0, "", "")
|
||||
|
||||
with mock.patch.object(host, "git", side_effect=_git):
|
||||
ok, msg = merge_task("/tmp", "/tmp", "b", "t",
|
||||
verify_argv=[])
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("已 abort", msg)
|
||||
|
||||
def test_merge_missing_lines_pyc_and_broken_link(self):
|
||||
# 2048, 2055-2056
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = os.path.join(tmp, "repo")
|
||||
os.makedirs(repo)
|
||||
with open(os.path.join(repo, "skip.pyc"), "wb") as f:
|
||||
f.write(b"\x00")
|
||||
os.symlink("/nonexistent-target-xyz",
|
||||
os.path.join(repo, "broken.txt"))
|
||||
with open(os.path.join(repo, "real.txt"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("hello\n")
|
||||
|
||||
def _git(args, cwd):
|
||||
if args[0] == "rev-parse":
|
||||
return self._git_result(0, "abc\n", "")
|
||||
if args[0] == "merge" and "-X" not in args:
|
||||
return self._git_result(1, "", "conflict")
|
||||
if args[0] == "merge" and "-X" in args:
|
||||
return self._git_result(0, "", "")
|
||||
if args[0] == "merge-base":
|
||||
return self._git_result(0, "base\n", "")
|
||||
if args[0] == "diff":
|
||||
return self._git_result(0, "+zzz-not-present-xyz\n",
|
||||
"")
|
||||
return self._git_result(0, "", "")
|
||||
|
||||
with mock.patch.object(host, "git", side_effect=_git):
|
||||
ok, msg = merge_task(repo, os.path.join(tmp, "wt"),
|
||||
"b", "t", verify_argv=[])
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("改动被丢弃", msg)
|
||||
|
||||
def test_notify_timeout_and_generic_failure(self):
|
||||
# 2191, 2197-2198
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
cfg = {"notify_command": "echo {task} {status} {text}"}
|
||||
with mock.patch("subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
"echo", 30)):
|
||||
msg = notify_failure(state_path, cfg, "t1",
|
||||
{"status": "failed", "text": "x"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
with mock.patch("subprocess.run",
|
||||
side_effect=RuntimeError("run down")):
|
||||
msg = notify_failure(state_path, cfg, "t1",
|
||||
{"status": "failed", "text": "x"})
|
||||
self.assertTrue(msg.startswith("FAILED t1"))
|
||||
|
||||
def test_create_pr_push_and_gh_failures(self):
|
||||
# 2322, 2331
|
||||
task = {"name": "t1", "branch": "host/t1",
|
||||
"status": "completed"}
|
||||
with mock.patch.object(host, "_github_remote_url",
|
||||
return_value="https://example.com/r.git"):
|
||||
with mock.patch.object(
|
||||
host, "git",
|
||||
return_value=self._git_result(1, "", "push boom")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
create_pr("/tmp", dict(task))
|
||||
with mock.patch.object(
|
||||
host, "git",
|
||||
return_value=self._git_result(0, "", "")):
|
||||
with mock.patch.object(
|
||||
host, "run_gh",
|
||||
return_value=self._git_result(1, "", "gh boom")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
create_pr("/tmp", dict(task))
|
||||
|
||||
def test_parse_pr_checks_short_tab_line(self):
|
||||
# 2356
|
||||
checks, all_pass = parse_pr_checks("build\t\n")
|
||||
self.assertEqual(checks, [])
|
||||
self.assertFalse(all_pass)
|
||||
|
||||
def test_dispatch_from_branch_worktree_failure(self):
|
||||
# 2453
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(host, "HERE", tmp):
|
||||
def _git(args, cwd):
|
||||
if args[0] == "worktree":
|
||||
return self._git_result(1, "", "wt fail")
|
||||
return self._git_result(0, "", "")
|
||||
|
||||
with mock.patch.object(host, "git", side_effect=_git):
|
||||
out, err = dispatch_from_branch(
|
||||
SimpleNamespace(), {"tasks": {}}, "t1",
|
||||
"do work", tmp, "main", tmp, None, None,
|
||||
"workspace-write", "never")
|
||||
self.assertIsNone(out)
|
||||
self.assertIn("失败", err)
|
||||
|
||||
|
||||
class MainBranchTestCase(unittest.TestCase):
|
||||
def _state_cfg(self, tmp, tasks):
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
cfg_path = os.path.join(tmp, "host-config.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks, "automations": []}, f)
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f)
|
||||
return state_path, cfg_path
|
||||
|
||||
def _run_main(self, argv, patches=()):
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
host.main()
|
||||
return buf.getvalue()
|
||||
|
||||
def test_pr_missing_repo_exits(self):
|
||||
# 3292-3293
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(
|
||||
tmp, {"t1": {"name": "t1"}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "pr", "--task", "t1"]
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
self._run_main(argv)
|
||||
self.assertEqual(cm.exception.code, 1)
|
||||
|
||||
def test_daemon_status_bad_pid(self):
|
||||
# 3376-3377
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
with open(os.path.join(tmp, "host-daemon.pid"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("abc")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-status"]
|
||||
out = self._run_main(argv)
|
||||
self.assertFalse(json.loads(out)["alive"])
|
||||
|
||||
def test_daemon_status_log_is_dir(self):
|
||||
# 3383-3384
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
with open(os.path.join(tmp, "host-daemon.pid"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
os.makedirs(os.path.join(tmp, "host-daemon.log"))
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-status"]
|
||||
out = self._run_main(argv)
|
||||
data = json.loads(out)
|
||||
self.assertTrue(data["alive"])
|
||||
self.assertEqual(data["log_tail"], "")
|
||||
|
||||
def test_daemon_stop_inner_break(self):
|
||||
# 3407-3408
|
||||
proc = subprocess.Popen(["sleep", "30"])
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
with open(os.path.join(tmp, "host-daemon.pid"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write(str(proc.pid))
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-stop"]
|
||||
out = self._run_main(argv)
|
||||
self.assertIn("daemon 已停", out)
|
||||
self.assertFalse(os.path.exists(
|
||||
os.path.join(tmp, "host-daemon.pid")))
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
proc.wait()
|
||||
|
||||
def test_daemon_stop_outer_except(self):
|
||||
# 3411-3412
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
pidfile = os.path.join(tmp, "host-daemon.pid")
|
||||
with open(pidfile, "w", encoding="utf-8") as f:
|
||||
f.write("999999")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-stop"]
|
||||
out = self._run_main(argv)
|
||||
self.assertIn("daemon 已停", out)
|
||||
self.assertFalse(os.path.exists(pidfile))
|
||||
|
||||
def test_daemon_stop_remove_failure(self):
|
||||
# 3415-3416
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
with open(os.path.join(tmp, "host-daemon.pid"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("999999")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-stop"]
|
||||
out = self._run_main(
|
||||
argv, patches=[mock.patch(
|
||||
"os.remove", side_effect=OSError("remove down"))])
|
||||
self.assertIn("daemon 已停", out)
|
||||
|
||||
def test_daemon_stale_pid_daemonize_false(self):
|
||||
# 3431-3435
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
with open(os.path.join(tmp, "host-daemon.pid"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("999999")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon"]
|
||||
out = self._run_main(
|
||||
argv, patches=[mock.patch.object(
|
||||
host, "daemonize", return_value=False)])
|
||||
self.assertIn("daemon 已在后台启动", out)
|
||||
|
||||
def test_verify_argv_empty_errors(self):
|
||||
# 3524
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(
|
||||
tmp, {"t1": {"name": "t1", "repo": "/tmp",
|
||||
"branch": "b", "cwd": tmp}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "merge", "--task", "t1",
|
||||
"--verify-argv"]
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
self._run_main(argv)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
def test_verify_audit_dispatch(self):
|
||||
# 3526-3527
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "verify-audit"]
|
||||
audit_mock = mock.Mock()
|
||||
self._run_main(
|
||||
argv, patches=[mock.patch.object(
|
||||
host, "_cmd_verify_audit", audit_mock)])
|
||||
audit_mock.assert_called_once()
|
||||
|
||||
def test_status_untracked_contents(self):
|
||||
# 3601
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(
|
||||
tmp, {"t1": {"name": "t1", "cwd": tmp}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "review", "--task", "t1"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch.object(host, "task_diff", return_value={
|
||||
"status": "S", "untracked": "U", "diff": "D",
|
||||
"untracked_contents": {"f.txt": "hello"}}),
|
||||
mock.patch.object(host, "open_review_comments",
|
||||
return_value=[]),
|
||||
])
|
||||
self.assertIn("--- untracked file: f.txt ---", out)
|
||||
self.assertIn("hello", out)
|
||||
|
||||
def test_merge_verify_argv_warning(self):
|
||||
# 3620
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(
|
||||
tmp, {"t1": {"name": "t1", "repo": "/tmp",
|
||||
"branch": "b", "cwd": tmp}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "merge", "--task", "t1",
|
||||
"--verify", "echo hi", "--verify-argv", "echo", "hi"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch.object(host, "merge_task",
|
||||
return_value=(True, "ok")),
|
||||
])
|
||||
self.assertIn("verify_argv 已指定", out)
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["tasks"]["t1"]["status"],
|
||||
"merged")
|
||||
|
||||
def test_fix_review_no_comments(self):
|
||||
# 3637-3638
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(
|
||||
tmp, {"t1": {"name": "t1", "thread_id": "th1"}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "fix-review", "--task", "t1"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch.object(host, "open_review_comments",
|
||||
return_value=[]),
|
||||
])
|
||||
self.assertIn("没有未处理的评审评论", out)
|
||||
|
||||
def test_interrupt_remove_failure(self):
|
||||
# 3929-3931
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {
|
||||
"t1": {"name": "t1", "thread_id": "th1",
|
||||
"turns": [{"result": {"turn_id": "turn9"}}]}})
|
||||
req_dir = os.path.join(tmp, "interrupt-requests")
|
||||
os.makedirs(req_dir)
|
||||
with open(os.path.join(req_dir, "t1.json"), "w",
|
||||
encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "interrupt", "--task", "t1"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch("os.remove",
|
||||
side_effect=OSError("remove down")),
|
||||
])
|
||||
self.assertIn("turn=turn9", out)
|
||||
with open(state_path, encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["tasks"]["t1"]["status"],
|
||||
"interrupted")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Coverage batch v4-D5: host.py leftover branches (wait-turn first
|
||||
slice, run-turn interrupt raise, daemon-stop inner break, daemon
|
||||
subcommand failure, interrupt poll paths, goal verify warning).
|
||||
|
||||
Stubbing discipline: the function under test is never stubbed. Patched
|
||||
collaborators are: time.monotonic / time.sleep / os.kill /
|
||||
subprocess.run / interrupt_turn / _wait_turn / dispatch /
|
||||
run_goal_loop / json.load (sinks, clocks and predicates). Every
|
||||
assertion reads a real return value or real stdout.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
from host import Server, _wait_turn
|
||||
|
||||
|
||||
def _server():
|
||||
srv = object.__new__(Server)
|
||||
srv.proc = None
|
||||
srv.codex_bin = "codex"
|
||||
srv.codex_home = None
|
||||
srv.approval = "deny"
|
||||
srv.model_provider = None
|
||||
srv.progress_interval = 30
|
||||
srv.send_lock = threading.Lock()
|
||||
srv.next_id = 1
|
||||
srv.pending = {}
|
||||
srv.pending_lock = threading.Lock()
|
||||
srv.turn_queues = {}
|
||||
srv.events = []
|
||||
srv.events_lock = threading.Lock()
|
||||
srv.restart_count = 0
|
||||
srv.trace_path = None
|
||||
srv._trace_lines = 0
|
||||
return srv
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
|
||||
def set_trace_file(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def resume_thread(self, thread_id):
|
||||
pass
|
||||
|
||||
def interrupt_turn(self, thread_id, turn_id):
|
||||
return {"error": "x"}
|
||||
|
||||
|
||||
class WaitTurnTestCase(unittest.TestCase):
|
||||
def test_first_slice_timeout(self):
|
||||
# 154: elapsed already past timeout before the first slice.
|
||||
q = queue.Queue()
|
||||
with mock.patch.object(
|
||||
host.time, "monotonic",
|
||||
side_effect=[100.0, 100.5]):
|
||||
self.assertEqual(_wait_turn(q, 0.1), ("timeout", None))
|
||||
|
||||
|
||||
class RunTurnInterruptRaiseTestCase(unittest.TestCase):
|
||||
def test_interrupt_turn_raises(self):
|
||||
# 777-780
|
||||
srv = _server()
|
||||
srv.request = mock.Mock(return_value={
|
||||
"result": {"turn": {"id": "turn1"}}})
|
||||
srv.interrupt_turn = mock.Mock(
|
||||
side_effect=RuntimeError("interrupt down"))
|
||||
with mock.patch.object(host, "_wait_turn",
|
||||
return_value=("interrupted-requested",
|
||||
None)):
|
||||
out = srv.run_turn("th1", "do work")
|
||||
self.assertEqual(out["status"], "interrupted")
|
||||
|
||||
|
||||
class MainLeftoverTestCase(unittest.TestCase):
|
||||
def _state_cfg(self, tmp, tasks):
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
cfg_path = os.path.join(tmp, "host-config.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": tasks, "automations": []}, f)
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f)
|
||||
return state_path, cfg_path
|
||||
|
||||
def _run_main(self, argv, patches=()):
|
||||
with contextlib.ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
host.main()
|
||||
return buf.getvalue()
|
||||
|
||||
def test_daemon_stop_inner_break(self):
|
||||
# 3407-3408: SIGTERM succeeds, the liveness probe then raises.
|
||||
# (A real sleep child would stay a zombie until reaped, so the
|
||||
# probe would succeed and the break would never fire; the
|
||||
# kill calls are therefore faked for this branch.)
|
||||
def _fake_kill(pid, sig):
|
||||
if sig == 0:
|
||||
raise ProcessLookupError("gone")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
pidfile = os.path.join(tmp, "host-daemon.pid")
|
||||
with open(pidfile, "w", encoding="utf-8") as f:
|
||||
f.write("12345")
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon-stop"]
|
||||
out = self._run_main(
|
||||
argv, patches=[mock.patch("os.kill",
|
||||
side_effect=_fake_kill)])
|
||||
self.assertIn("daemon 已停", out)
|
||||
self.assertFalse(os.path.exists(pidfile))
|
||||
|
||||
def test_daemon_subcommand_failure(self):
|
||||
# 3504-3505
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "daemon", "--foreground",
|
||||
"--interval", "300"]
|
||||
old_term = signal.getsignal(signal.SIGTERM)
|
||||
old_int = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def sender():
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sender_thread = threading.Thread(target=sender, daemon=True)
|
||||
try:
|
||||
sender_thread.start()
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch("subprocess.run",
|
||||
side_effect=RuntimeError("child down")),
|
||||
])
|
||||
finally:
|
||||
sender_thread.join(timeout=5)
|
||||
signal.signal(signal.SIGTERM, old_term)
|
||||
signal.signal(signal.SIGINT, old_int)
|
||||
self.assertIn("daemon subcommand", out)
|
||||
|
||||
def test_interrupt_poll_responded(self):
|
||||
# 3963-3964
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {
|
||||
"t1": {"name": "t1", "thread_id": "th1",
|
||||
"status": "interrupted",
|
||||
"turns": [{"result": {"turn_id": "turn9"}}]}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "interrupt", "--task", "t1"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch("time.sleep", lambda *a, **k: None),
|
||||
])
|
||||
self.assertIn("属主已响应", out)
|
||||
|
||||
def test_interrupt_poll_read_failure(self):
|
||||
# 3965-3967 (+ else branch): state reads fail during polling.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {
|
||||
"t1": {"name": "t1", "thread_id": "th1",
|
||||
"status": "running",
|
||||
"turns": [{"result": {"turn_id": "turn9"}}]}})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "interrupt", "--task", "t1"]
|
||||
real_load = json.load
|
||||
calls = {"n": 0}
|
||||
|
||||
def _load(fp, *a, **k):
|
||||
calls["n"] += 1
|
||||
if calls["n"] <= 2:
|
||||
return real_load(fp, *a, **k)
|
||||
raise RuntimeError("poll read down")
|
||||
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch("time.sleep", lambda *a, **k: None),
|
||||
mock.patch("json.load", side_effect=_load),
|
||||
])
|
||||
self.assertIn("10 秒内未响应", out)
|
||||
|
||||
def test_goal_verify_argv_warning(self):
|
||||
# 4025
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
state_path, cfg_path = self._state_cfg(tmp, {})
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "goal", "--name", "g",
|
||||
"--task", "t", "--repo", "/tmp",
|
||||
"--verify", "echo hi", "--verify-argv", "echo", "hi"]
|
||||
out = self._run_main(argv, patches=[
|
||||
mock.patch.object(host, "Server",
|
||||
lambda **kw: _FakeServer()),
|
||||
mock.patch.object(host, "dispatch",
|
||||
return_value={"name": "g"}),
|
||||
mock.patch.object(host, "run_goal_loop",
|
||||
return_value=None),
|
||||
])
|
||||
self.assertIn("verify_argv 已指定", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Coverage batch v4-D6: host.py parallel-branch fallback paths
|
||||
(4186 dep-branch dispatch failure, 4198 dep cwd copy).
|
||||
|
||||
Stubbing discipline: the branch code under test is never stubbed.
|
||||
Patched collaborators are dispatch, dispatch_from_branch,
|
||||
run_task_turn_with_retry and copy_tree_excluding_git (all of them
|
||||
sinks the branch delegates to). Assertions read real stdout and the
|
||||
real copy_tree call record.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import host
|
||||
|
||||
|
||||
class _FakeServer:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
|
||||
def set_trace_file(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class ParallelFallbackTestCase(unittest.TestCase):
|
||||
def test_dep_branch_dispatch_falls_back_to_head_plus_copy(self):
|
||||
# 4186 + 4198
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dep_dir = os.path.join(tmp, "dep-cwd")
|
||||
os.makedirs(dep_dir)
|
||||
with open(os.path.join(dep_dir, "seed.txt"), "w") as f:
|
||||
f.write("seed")
|
||||
new_dir = os.path.join(tmp, "new-cwd")
|
||||
jobs_path = os.path.join(tmp, "jobs.json")
|
||||
jobs = [
|
||||
{"name": "j1", "task": "t1", "repo": "/tmp/repo1"},
|
||||
{"name": "j2", "task": "t2", "repo": "/tmp/repo1",
|
||||
"depends_on": ["j1"]},
|
||||
]
|
||||
with open(jobs_path, "w", encoding="utf-8") as f:
|
||||
json.dump(jobs, f)
|
||||
state_path = os.path.join(tmp, "host-state.json")
|
||||
cfg_path = os.path.join(tmp, "host-config.json")
|
||||
with open(state_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"tasks": {}, "automations": []}, f)
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f)
|
||||
|
||||
def _dispatch(srv, state, name, task, repo, *a, **k):
|
||||
if name == "j1":
|
||||
return {"name": "j1", "status": "completed",
|
||||
"branch": "b1", "cwd": dep_dir}
|
||||
return {"name": "j2", "status": "completed",
|
||||
"cwd": new_dir}
|
||||
|
||||
copy_calls = []
|
||||
argv = ["host.py", "--state", state_path,
|
||||
"--config", cfg_path, "parallel",
|
||||
"--jobs", jobs_path]
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
with mock.patch.object(sys, "argv", argv):
|
||||
with mock.patch.object(
|
||||
host, "Server",
|
||||
lambda **kw: _FakeServer()):
|
||||
with mock.patch.object(
|
||||
host, "dispatch",
|
||||
side_effect=_dispatch):
|
||||
with mock.patch.object(
|
||||
host, "dispatch_from_branch",
|
||||
return_value=(None, "boom")):
|
||||
with mock.patch.object(
|
||||
host,
|
||||
"run_task_turn_with_retry",
|
||||
return_value={"status": "completed",
|
||||
"text": "ok"}):
|
||||
with mock.patch.object(
|
||||
host,
|
||||
"copy_tree_excluding_git",
|
||||
side_effect=lambda s, d: copy_calls.append((s, d))):
|
||||
host.main()
|
||||
out = buf.getvalue()
|
||||
self.assertIn("从依赖分支派发失败", out)
|
||||
self.assertEqual(copy_calls, [(dep_dir, new_dir)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user