新增 run_tests.py:三套件统一入口,只加仓库根(deploy 套件 再加 deploy/ 目录在根之前),各套件走独立子进程;verify 作 业改走运行器,coverage 作业因子进程测不到覆盖率保持原 discover 命令。tests/ 13 个文件的 sys.path 前置 import 全部 改回正常 import(含 test_host_trace 的 try/except 回落); deploy 的 deploy 目录插入与 verify-multiuser 的 spec 加载按 陷阱 2 如实保留(仅删 verify-multiuser 冗余的根插入)。 .coveragerc omit 加 */run_tests.py,使新运行器文件不计入 生产覆盖率。门槛未动(仍 75%),未补新测试。
372 lines
15 KiB
Python
372 lines
15 KiB
Python
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from unittest import mock
|
|
import host
|
|
|
|
|
|
class FakeServer:
|
|
# Fake app-server: records every call in order, never spawns a
|
|
# real codex binary. request returns {"result": ...} per method,
|
|
# or an error response when the method is in error_methods.
|
|
# resume_thread / archive_thread mimic the real Server methods:
|
|
# they raise RuntimeError on an error response.
|
|
|
|
def __init__(self, **kwargs):
|
|
self.calls = []
|
|
self.results = {
|
|
"thread/list": {"data": [{"id": "th-1"}]},
|
|
"thread/read": {"thread": {"id": "th-1"}},
|
|
"thread/name/set": {},
|
|
"thread/archive": {},
|
|
"thread/unarchive": {"thread": {"id": "th-1"}},
|
|
"thread/compact/start": {},
|
|
}
|
|
self.error_methods = set()
|
|
self.resume_error = False
|
|
self.archive_error = False
|
|
|
|
def initialize(self):
|
|
self.calls.append(("initialize",))
|
|
return {}
|
|
|
|
def close(self):
|
|
self.calls.append(("close",))
|
|
|
|
def request(self, method, params=None, timeout=120):
|
|
self.calls.append(("request", method, params))
|
|
if method in self.error_methods:
|
|
return {"error": {"message": "boom"}}
|
|
return {"result": self.results.get(method, {})}
|
|
|
|
def resume_thread(self, thread_id):
|
|
self.calls.append(("resume", thread_id))
|
|
if self.resume_error:
|
|
raise RuntimeError("thread/resume 失败:boom")
|
|
return {"thread": {"id": thread_id}}
|
|
|
|
def archive_thread(self, thread_id):
|
|
self.calls.append(("archive", thread_id))
|
|
if self.archive_error:
|
|
raise RuntimeError("thread/archive 失败:boom")
|
|
return {"result": {}}
|
|
|
|
|
|
class ThreadCliTest(unittest.TestCase):
|
|
def _run(self, fake, cli_args, tasks=None):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path = os.path.join(tmp, "host-state.json")
|
|
with open(state_path, "w", encoding="utf-8") as f:
|
|
json.dump({"tasks": tasks or {}, "automations": []}, f)
|
|
cfg_path = os.path.join(tmp, "cfg.json")
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump({"state": state_path}, f)
|
|
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
|
argv += cli_args
|
|
out = io.StringIO()
|
|
code = 0
|
|
with mock.patch.object(host, "Server", lambda **kw: fake):
|
|
with mock.patch.object(sys, "argv", argv):
|
|
with redirect_stdout(out):
|
|
try:
|
|
host.main()
|
|
except SystemExit as e:
|
|
code = e.code if isinstance(e.code, int) else 1
|
|
return code, out.getvalue(), state_path
|
|
|
|
def _calls_before_close(self, fake):
|
|
return [c for c in fake.calls if c[0] != "close"
|
|
and c[0] != "initialize"]
|
|
|
|
def test_list_no_filters_no_resume(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(fake, ["thread-list"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "thread/list", {})])
|
|
|
|
def test_list_cwd_and_archived_passthrough(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-list", "--cwd", "/tmp/work", "--archived"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"data": [{"id": "th-1"}]})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "thread/list",
|
|
{"cwd": "/tmp/work", "archived": True})])
|
|
|
|
def test_read_defaults_include_turns_false_and_resume_first(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(fake, ["thread-read", "--thread-id", "th-1"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("resume", "th-1"),
|
|
("request", "thread/read",
|
|
{"threadId": "th-1", "includeTurns": False}),
|
|
])
|
|
|
|
def test_read_include_turns_flag(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-read", "--thread-id", "th-1", "--include-turns"])
|
|
self.assertEqual(code, 0)
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls[1], ("request", "thread/read",
|
|
{"threadId": "th-1",
|
|
"includeTurns": True}))
|
|
|
|
def test_rename_resume_first_and_params(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-rename", "--thread-id", "th-1",
|
|
"--name", "new-name"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("resume", "th-1"),
|
|
("request", "thread/name/set",
|
|
{"threadId": "th-1", "name": "new-name"}),
|
|
])
|
|
|
|
def test_archive_no_resume(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-archive", "--thread-id", "th-1"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("archive", "th-1")])
|
|
|
|
def test_unarchive_no_resume(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-unarchive", "--thread-id", "th-1"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("request", "thread/unarchive", {"threadId": "th-1"}),
|
|
])
|
|
|
|
def test_unarchive_resume_failure_does_not_block(self):
|
|
# 回归:已归档 thread 的 resume 会失败;unarchive 不再调
|
|
# resume,所以 resume 失败也不应挡住 unarchive(此前
|
|
# resume 抛错 → CLI 退出 1 → 服务端回 502)。
|
|
fake = FakeServer()
|
|
fake.resume_error = True
|
|
code, out, _ = self._run(
|
|
fake, ["thread-unarchive", "--thread-id", "th-1"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"thread": {"id": "th-1"}})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("request", "thread/unarchive", {"threadId": "th-1"}),
|
|
])
|
|
|
|
def test_compact_resume_first(self):
|
|
fake = FakeServer()
|
|
code, out, _ = self._run(
|
|
fake, ["thread-compact", "--thread-id", "th-1"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("resume", "th-1"),
|
|
("request", "thread/compact/start", {"threadId": "th-1"}),
|
|
])
|
|
|
|
def test_method_error_is_error_json_and_nonzero(self):
|
|
fake = FakeServer()
|
|
fake.error_methods = {"thread/list"}
|
|
code, out, _ = self._run(fake, ["thread-list"])
|
|
self.assertNotEqual(code, 0)
|
|
parsed = json.loads(out)
|
|
self.assertEqual(set(parsed.keys()), {"error"})
|
|
self.assertIn("thread/list", parsed["error"])
|
|
|
|
def test_resume_failure_is_error_json_and_nonzero(self):
|
|
fake = FakeServer()
|
|
fake.resume_error = True
|
|
code, out, _ = self._run(
|
|
fake, ["thread-rename", "--thread-id", "th-1",
|
|
"--name", "new-name"])
|
|
self.assertNotEqual(code, 0)
|
|
parsed = json.loads(out)
|
|
self.assertEqual(set(parsed.keys()), {"error"})
|
|
# rename must not reach thread/name/set when resume fails
|
|
methods = [c[1] for c in fake.calls if c[0] == "request"]
|
|
self.assertNotIn("thread/name/set", methods)
|
|
|
|
def test_old_compact_unchanged(self):
|
|
fake = FakeServer()
|
|
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
|
"cwd": "/tmp/work"}}
|
|
code, out, _ = self._run(fake, ["compact", "--task", "t1"],
|
|
tasks=tasks)
|
|
self.assertEqual(code, 0)
|
|
# old human-readable output, not the frozen JSON contract
|
|
self.assertIn("compact t1:", out)
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [
|
|
("resume", "th-1"),
|
|
("request", "thread/compact/start", {"threadId": "th-1"}),
|
|
])
|
|
|
|
def test_old_archive_unchanged(self):
|
|
fake = FakeServer()
|
|
tasks = {"t1": {"thread_id": "th-1", "status": "running",
|
|
"cwd": "/tmp/work"}}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path = os.path.join(tmp, "host-state.json")
|
|
with open(state_path, "w", encoding="utf-8") as f:
|
|
json.dump({"tasks": tasks, "automations": []}, f)
|
|
cfg_path = os.path.join(tmp, "cfg.json")
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump({"state": state_path}, f)
|
|
out = io.StringIO()
|
|
code = 0
|
|
with mock.patch.object(host, "Server", lambda **kw: fake):
|
|
with mock.patch.object(
|
|
sys, "argv",
|
|
["host.py", "--config", cfg_path,
|
|
"--state", state_path,
|
|
"archive", "--task", "t1"]):
|
|
with redirect_stdout(out):
|
|
try:
|
|
host.main()
|
|
except SystemExit as e:
|
|
code = e.code if isinstance(e.code, int) else 1
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("archived t1", out.getvalue())
|
|
saved = json.loads(Path(state_path).read_text(encoding="utf-8"))
|
|
self.assertEqual(saved["tasks"]["t1"]["status"], "archived")
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("archive", "th-1")])
|
|
|
|
|
|
class SkillsCliTest(unittest.TestCase):
|
|
# skills/mcp CLI 子命令:与 thread-* 同款冻结契约(纯 JSON
|
|
# stdout、失败非零),但不带 threadId、不 resume。
|
|
|
|
def _run(self, fake, cli_args):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
state_path = os.path.join(tmp, "host-state.json")
|
|
with open(state_path, "w", encoding="utf-8") as f:
|
|
json.dump({"tasks": {}, "automations": []}, f)
|
|
cfg_path = os.path.join(tmp, "cfg.json")
|
|
with open(cfg_path, "w", encoding="utf-8") as f:
|
|
json.dump({"state": state_path}, f)
|
|
argv = ["host.py", "--config", cfg_path, "--state", state_path]
|
|
argv += cli_args
|
|
out = io.StringIO()
|
|
code = 0
|
|
with mock.patch.object(host, "Server", lambda **kw: fake):
|
|
with mock.patch.object(sys, "argv", argv):
|
|
with redirect_stdout(out):
|
|
try:
|
|
host.main()
|
|
except SystemExit as e:
|
|
code = e.code if isinstance(e.code, int) else 1
|
|
return code, out.getvalue()
|
|
|
|
def _calls_before_close(self, fake):
|
|
return [c for c in fake.calls if c[0] != "close"
|
|
and c[0] != "initialize"]
|
|
|
|
def _fake_with(self, results):
|
|
fake = FakeServer()
|
|
fake.results.update(results)
|
|
return fake
|
|
|
|
def test_skills_list_cwd_maps_to_cwds(self):
|
|
fake = self._fake_with({
|
|
"skills/list": {"data": [{"cwd": "/tmp/work", "errors": [],
|
|
"skills": []}]}})
|
|
code, out = self._run(fake, ["skills-list", "--cwd", "/tmp/work"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"data": [{"cwd": "/tmp/work",
|
|
"errors": [],
|
|
"skills": []}]})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "skills/list",
|
|
{"cwds": ["/tmp/work"]})])
|
|
|
|
def test_skills_list_no_cwd_empty_params(self):
|
|
fake = self._fake_with({"skills/list": {"data": []}})
|
|
code, out = self._run(fake, ["skills-list"])
|
|
self.assertEqual(code, 0)
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "skills/list", {})])
|
|
|
|
def test_apps_list(self):
|
|
fake = self._fake_with({"app/list": {"data": []}})
|
|
code, out = self._run(fake, ["apps-list"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"data": []})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "app/list", {})])
|
|
|
|
def test_mcp_servers_list(self):
|
|
fake = self._fake_with({"mcpServerStatus/list": {"data": []}})
|
|
code, out = self._run(fake, ["mcp-servers-list"])
|
|
self.assertEqual(code, 0)
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "mcpServerStatus/list", {})])
|
|
|
|
def test_mcp_servers_reload(self):
|
|
fake = self._fake_with({"config/mcpServer/reload": {}})
|
|
code, out = self._run(fake, ["mcp-servers-reload"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "config/mcpServer/reload",
|
|
None)])
|
|
|
|
def test_oauth_login_full_flags(self):
|
|
fake = self._fake_with({
|
|
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login",
|
|
"loginId": "lg-1"}})
|
|
code, out = self._run(fake, ["mcp-server-oauth-login",
|
|
"--server-name", "srv",
|
|
"--scopes", "a,b",
|
|
"--timeout-secs", "30"])
|
|
self.assertEqual(code, 0)
|
|
self.assertEqual(json.loads(out), {"authorizationUrl":
|
|
"https://x/login",
|
|
"loginId": "lg-1"})
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
|
{"name": "srv", "scopes": ["a", "b"],
|
|
"timeoutSecs": 30})])
|
|
|
|
def test_oauth_login_minimal(self):
|
|
fake = self._fake_with({
|
|
"mcpServer/oauth/login": {"authorizationUrl": "https://x/login"}})
|
|
code, _ = self._run(fake, ["mcp-server-oauth-login",
|
|
"--server-name", "srv"])
|
|
self.assertEqual(code, 0)
|
|
calls = self._calls_before_close(fake)
|
|
self.assertEqual(calls, [("request", "mcpServer/oauth/login",
|
|
{"name": "srv"})])
|
|
|
|
def test_method_error_is_error_json_and_nonzero(self):
|
|
fake = FakeServer()
|
|
fake.error_methods = {"app/list"}
|
|
code, out = self._run(fake, ["apps-list"])
|
|
self.assertNotEqual(code, 0)
|
|
parsed = json.loads(out)
|
|
self.assertEqual(set(parsed.keys()), {"error"})
|
|
self.assertIn("app/list", parsed["error"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|