Files
Agent-System-Dev/tests/test_svc_plugins.py
T
weichenandCursor 1cf2f533f7 Compile selected plugins into the svc deploy binary
Why: deploy needs to choose registered plugins and ship them inside one agent-system, then refuse the release when they are not active.
Evidence: python3 -m unittest tests.test_svc_plugins tests.test_svc_user_help tests.test_svc tests.test_svc_u2 (SELF, 63 passed, 1 skipped). Real compose build and make test-lifecycle are NOT_RUN.
Impact: --with is mutually exclusive with --from rdm and --from release. Existing non-interactive deploys stay without plugins unless plugins= is already recorded. Changing the plugin id set on a live service requires --allow-source-switch.
Refs: .task/feature-svc-plugin-compose.md modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 16:08:13 +00:00

298 lines
12 KiB
Python

"""svc.sh --with 编译插件组合的桩回归。
用临时 git 仓和桩二进制覆盖:登记校验、dry-run、与 rdm/release 互斥、
首次部署、非交互复用、换集合门禁、交互勾选,以及激活校验里“命令被禁用则启用”。
SVC_COMPOSE_STUB=1 不执行 go build。真实组合构建与 make test-lifecycle 不在这里。
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
STUB = r"""#!/usr/bin/env bash
set -u
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
mkdir -p "$trace_dir"
case "${1:-}" in
version)
if [ "${2:-}" = "--json" ]; then
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
exit 0
fi
echo "stub-agent-system 1.0"
exit 0 ;;
serve)
shift
addr=""
while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
done
port="${addr##*:}"; host="${addr%:*}"
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
import json, os, sys, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
class H(BaseHTTPRequestHandler):
def _send(self, body):
raw = body.encode() if isinstance(body, str) else body
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path == '/api/plugins':
self._send(json.dumps([{"manifest": {"id": "demo"}, "backend": {"mode": "inproc"}}]))
return
if path == '/api/registry':
self._send(json.dumps({"compiled": ["demo"]}))
return
if path == '/api/commands':
enabled = os.path.exists(os.path.join(trace, 'plugin-enabled'))
self._send(json.dumps([{"id": "demo.ping", "enabled": enabled}]))
return
self._send('{"ok":true}')
def do_HEAD(self):
self._send(b'')
def do_POST(self):
path = self.path.split('?', 1)[0]
n = int(self.headers.get('Content-Length') or 0)
self.rfile.read(n)
if path.endswith('/enabled'):
open(os.path.join(trace, 'plugin-enabled'), 'w').close()
open(os.path.join(trace, 'enable-post'), 'w').write(path + '\n')
self._send('{"ok":true}')
def log_message(self, *args):
pass
ppid = os.getppid()
def watch():
while True:
try:
if os.getppid() != ppid:
os._exit(0)
except Exception:
os._exit(0)
time.sleep(0.3)
threading.Thread(target=watch, daemon=True).start()
try:
HTTPServer((host, port), H).serve_forever()
except Exception:
os._exit(1)
PYSRV
child=$!
wait "$child"
;;
*)
exit 0 ;;
esac
"""
class PluginComposeTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.mkdtemp(prefix="svc-plugins-")
self.addCleanup(shutil.rmtree, tmp, True)
self.root = Path(tmp)
(self.root / "scripts").mkdir(parents=True)
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
self.trace = self.root / "trace"
self.stub = self.root / "stub-agent-system"
self.stub.write_text(STUB)
self.stub.chmod(0o755)
(self.root / "plugins").mkdir()
(self.root / "Makefile").write_text(
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub)
self.plugin = Path(tempfile.mkdtemp(prefix="svc-plugin-src-"))
self.addCleanup(shutil.rmtree, self.plugin, True)
self.cache = self.root / "plugin-cache"
self.rdm_cache = self.root / "rdm-cache"
self.env = os.environ.copy()
self.env.update({
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "svc plugins", "GIT_COMMITTER_NAME": "svc plugins",
"GIT_AUTHOR_EMAIL": "svc-plugins@example.invalid",
"GIT_COMMITTER_EMAIL": "svc-plugins@example.invalid",
"SVC_STUB_TRACE": str(self.trace),
"SVC_SRC_DIR": str(self.root / "srccache"),
"SVC_COMPOSE_STUB": "1",
"SVC_PLUGIN_SRC_ROOT": str(self.cache),
"SVC_RDM_SRC_DIR": str(self.rdm_cache),
"SVC_DEMO_ORIGIN": self.plugin.resolve().as_uri(),
})
for key in list(self.env):
if key.startswith("SVC_") and key not in (
"SVC_STUB_TRACE", "SVC_SRC_DIR", "SVC_COMPOSE_STUB",
"SVC_PLUGIN_SRC_ROOT", "SVC_RDM_SRC_DIR", "SVC_DEMO_ORIGIN",
):
self.env.pop(key)
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
self.env.pop(key, None)
self.git(self.plugin, "init", "--initial-branch=main")
(self.plugin / "README").write_text("demo\n")
self.git(self.plugin, "add", "README")
self.git(self.plugin, "commit", "-m", "demo plugin")
self.plugin_sha = self.git(self.plugin, "rev-parse", "HEAD").stdout.strip()
self.write_registry([self.plugin_entry()])
self.git(self.root, "init", "--initial-branch=main", ".")
self.git(self.root, "add", ".")
self.git(self.root, "commit", "-m", "fixture")
def plugin_entry(self, plugin_id="demo", web=""):
entry = {
"id": plugin_id,
"name": plugin_id,
"repo": self.plugin.resolve().as_uri(),
"repoEnv": "SVC_DEMO_ORIGIN",
"defaultRef": "origin/main",
"goModule": "example.com/" + plugin_id,
"entryPackage": "example.com/%s/plugin" % plugin_id,
"commandPrefix": plugin_id + ".",
}
if web:
entry["web"] = web
return entry
def write_registry(self, plugins):
import json
(self.root / "plugins" / "registry.json").write_text(
json.dumps({"plugins": plugins}, ensure_ascii=False, indent=2) + "\n")
def git(self, cwd, *args):
proc = subprocess.run(
["git", *args], cwd=cwd, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def tearDown(self):
runtime = self.root / "runtime"
if not runtime.is_dir():
return
for d in sorted(runtime.iterdir()):
if (d / "svc.pid").exists():
subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
cwd=self.root, env=self.env, timeout=60,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def svc(self, *args, check=True, env=None, input_text=None):
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
cwd=self.root, env=env if env is not None else self.env, text=True,
input=input_text, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=120)
if check:
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def kv(self, name, key):
envfile = self.root / "runtime" / name / "svc.env"
if not envfile.exists():
return None
for line in envfile.read_text().splitlines():
if line.startswith(key + "="):
return line[len(key) + 1:]
return None
def pid_alive(self, name):
pidf = self.root / "runtime" / name / "svc.pid"
if not pidf.exists():
return False
try:
os.kill(int(pidf.read_text().strip()), 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def test_help_plugins_and_rejects_with_off_deploy(self):
help_out = self.svc("help", "plugins")
self.assertIn("registry.json", help_out.stdout)
self.assertIn("--with", help_out.stdout)
self.assertIn("allow-source-switch", help_out.stdout)
rejected = self.svc("pdemo", "start", "--with", "demo", check=False)
self.assertEqual(rejected.returncode, 2, rejected.stdout)
self.assertIn("仅用于 deploy", rejected.stdout)
def test_dry_run_prints_plugin_sha_without_listening(self):
result = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981", "--dry-run")
self.assertIn(self.plugin_sha, result.stdout)
self.assertIn("插件:", result.stdout)
self.assertFalse((self.root / "runtime" / "pdemo" / "svc.pid").exists())
def test_unknown_plugin_and_two_web_and_rdm_are_rejected(self):
unknown = self.svc("pdemo", "deploy", "--from", "local", "--with", "nope",
"--dry-run", check=False)
self.assertNotEqual(unknown.returncode, 0)
self.assertIn("没有插件", unknown.stdout)
self.write_registry([
self.plugin_entry("a", web="web"),
self.plugin_entry("b", web="web"),
])
two = self.svc("pdemo", "deploy", "--from", "local", "--with", "a,b",
"--dry-run", check=False)
self.assertNotEqual(two.returncode, 0)
self.assertIn("web", two.stdout)
rdm = self.svc("pdemo", "deploy", "--from", "rdm", "--with", "demo",
"--dry-run", check=False)
self.assertNotEqual(rdm.returncode, 0)
self.assertIn("不能与 --with", rdm.stdout)
self.assertFalse(self.rdm_cache.exists())
def test_deploy_reuses_selection_and_gates_clearing(self):
first = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981")
self.assertIn("插件已激活", first.stdout)
self.assertTrue((self.trace / "enable-post").exists())
plugins = self.kv("pdemo", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@" + self.plugin_sha), plugins)
source = self.kv("pdemo", "source")
self.assertIn("+demo@", source)
release = (self.root / "runtime" / "pdemo" / "current" / "release.json").read_text()
self.assertIn(self.plugin_sha, release)
self.assertTrue(self.pid_alive("pdemo"))
again = self.svc("pdemo", "deploy", "--from", "local", "--port", "18981")
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
self.assertIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件已激活", again.stdout)
pid = int((self.root / "runtime" / "pdemo" / "svc.pid").read_text())
blocked = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--port", "18981", check=False)
self.assertNotEqual(blocked.returncode, 0, blocked.stdout)
self.assertIn("allow-source-switch", blocked.stdout)
self.assertTrue(self.pid_alive("pdemo"))
self.assertEqual(int((self.root / "runtime" / "pdemo" / "svc.pid").read_text()), pid)
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
cleared = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--allow-source-switch", "--port", "18981")
self.assertIsNone(self.kv("pdemo", "plugins"))
self.assertNotIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件集合已确认", cleared.stdout)
self.svc("pdemo", "stop")
def test_prompt_selects_numbered_plugin_on_first_deploy(self):
env = dict(self.env, SVC_FORCE_PLUGIN_PROMPT="1")
result = self.svc("pprompt", "deploy", "--from", "local", "--port", "18982",
env=env, input_text="1\n")
self.assertIn("插件已激活", result.stdout)
plugins = self.kv("pprompt", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@"), plugins)
self.svc("pprompt", "stop")
if __name__ == "__main__":
unittest.main()