Files
codex-app-server/cloud/tests/test_task_environments.py
T
host ebc5624893 console: React 生产级重写(左列表 + 中对话 + 右详情)
旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物
(309525 字节,sha 90345ec8…,重建字节一致),源码在
cloud/console-react/,改页面只改 src 再 npm run build:console,
产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话
(实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情
(元数据、操作、评审、审批、协调子任务、子代理、线程)。
API 与后端语义未改,key 只存浏览器 localStorage。
6 个 console 标记单测随实现改名更新(意图不变:壳确实调了
这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在);
P1 标记单测未动且通过。
2026-10-29 06:32:38 +08:00

504 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云端环境单测:选、建、管 + 任务绑定环境。
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
SQLite;worker 侧注入假 git runner(clone/fetch/checkout 只
操作临时目录,不调真实 git);console 侧只查 HTML 标记。
链路覆盖:建环境(creating)-> worker 准备(ready/failed)
-> 建任务选环境(执行器继承/冲突拒绝)-> 任务派发指到环境
检出 -> 归档(进行中任务在用时拒绝、幂等)。
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
import uuid
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _req(port, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _set_env_status(db_path, env_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE environments SET status = ? WHERE id = ?",
(status, env_id))
conn.commit()
finally:
conn.close()
def _set_task_status(db_path, task_id, status):
conn = sqlite3.connect(db_path)
try:
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
(status, task_id))
conn.commit()
finally:
conn.close()
class ServerEnvironmentsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.other_id, self.other_key = self.auth.create_user("other")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = _req(
self.port, "POST", "/projects",
{"name": "demo", "repo_url": "https://github.com/octocat/Hello-World.git"},
self.owner_key)
self.assertEqual(status, 201, self.project)
status, self.plain_project = _req(
self.port, "POST", "/projects", {"name": "plain"},
self.owner_key)
self.assertEqual(status, 201, self.plain_project)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
def _create_env(self, project_id=None, name="dev", **extra):
body = {"project_id": project_id or self.project["id"],
"name": name}
body.update(extra)
return _req(self.port, "POST", "/environments", body,
self.owner_key)
def _ready_env(self, name="dev", **extra):
status, env = self._create_env(name=name, **extra)
self.assertEqual(status, 201, env)
_set_env_status(self.db_path, env["id"], "ready")
return env
def test_create_environment_roundtrip(self):
status, env = self._create_env(
name="dev", branch="main", executor="local")
self.assertEqual(status, 201, env)
self.assertEqual(env["status"], "creating")
self.assertEqual(env["project_id"], self.project["id"])
self.assertEqual(env["branch"], "main")
self.assertEqual(env["executor"], "local")
self.assertEqual(env["repo_url"],
"https://github.com/octocat/Hello-World.git")
status, got = _req(
self.port, "GET", "/environments/" + env["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(got["id"], env["id"])
def test_create_environment_validation(self):
# 项目没有 repo_url:环境无从检出。
status, _ = self._create_env(
project_id=self.plain_project["id"], name="x")
self.assertEqual(status, 400)
# 名字、分支、执行器非法。
status, _ = self._create_env(name=" ")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", branch="../evil")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", branch="-bad")
self.assertEqual(status, 400)
status, _ = self._create_env(name="x", executor="cloud")
self.assertEqual(status, 400)
# 非成员、不存在的环境。
status, _ = _req(
self.port, "POST", "/environments",
{"project_id": self.project["id"], "name": "x"},
self.other_key)
self.assertEqual(status, 403)
status, _ = _req(self.port, "GET", "/environments/nope",
key=self.owner_key)
self.assertEqual(status, 404)
def test_environment_name_unique_per_project(self):
status, _ = self._create_env(name="dev")
self.assertEqual(status, 201)
status, _ = self._create_env(name="dev")
self.assertEqual(status, 409)
# 另一项目同名可以;归档后原项目同名可重用。
status, env2 = self._create_env(
project_id=self.plain_project["id"], name="dev")
# plain 项目无 repo_url,上面应 400——换一个带仓库的项目测同名。
self.assertEqual(status, 400)
status, other = _req(
self.port, "POST", "/projects",
{"name": "demo2",
"repo_url": "https://github.com/octocat/Hello-World.git"},
self.owner_key)
self.assertEqual(status, 201)
status, _ = self._create_env(project_id=other["id"], name="dev")
self.assertEqual(status, 201)
def test_list_environments_and_archive(self):
env = self._ready_env(name="dev")
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(len(listing["environments"]), 1)
# 缺 project_id 400;非成员 403。
status, _ = _req(self.port, "GET", "/environments",
key=self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.other_key)
self.assertEqual(status, 403)
# 归档后默认列表排除,include_archived=1 才出现;再归档幂等。
status, archived = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(archived["status"], "archived")
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"],
key=self.owner_key)
self.assertEqual(listing["environments"], [])
status, listing = _req(
self.port, "GET",
"/environments?project_id=" + self.project["id"]
+ "&include_archived=1",
key=self.owner_key)
self.assertEqual(len(listing["environments"]), 1)
status, again = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
def test_archive_blocked_when_task_using_it(self):
env = self._ready_env(name="dev")
status, task = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 201, task)
status, _ = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 409)
_set_task_status(self.db_path, task["id"], "completed")
status, archived = _req(
self.port, "POST", "/environments/%s/archive" % env["id"],
{}, self.owner_key)
self.assertEqual(status, 200)
self.assertEqual(archived["status"], "archived")
def test_task_with_environment_rules(self):
# creating 的环境不能建任务。
status, env = self._create_env(name="dev", executor="docker")
self.assertEqual(status, 201)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
# ready 后:执行器继承、冲突拒绝、cloud 拒绝、跨项目拒绝。
_set_env_status(self.db_path, env["id"], "ready")
status, task = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 201, task)
self.assertEqual(task["executor"], "docker")
self.assertEqual(task["environment_id"], env["id"])
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"], "executor": "local"},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"], "executor": "cloud"},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.plain_project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": "nope"},
self.owner_key)
self.assertEqual(status, 404)
# failed 的环境也不能建任务。
_set_env_status(self.db_path, env["id"], "failed")
status, _ = _req(
self.port, "POST", "/tasks",
{"project_id": self.project["id"], "prompt": "p",
"environment_id": env["id"]},
self.owner_key)
self.assertEqual(status, 400)
def test_console_has_environment_ui(self):
path = Path(__file__).resolve().parents[1] / "console.html"
html = path.read_text(encoding="utf-8")
# React 版(2026-10-29 起):旧单页的 loadEnvironments /
# createEnvironment / newEnv* id 已不存在,环境 UI 改为
# React 组件(EnvSection + useCatalog),断言意图不变,
# 只换成构建产物里实际存在的标记:环境栏、建环境表单、
# 刷新/新建/归档操作与环境列表接口。
for marker in ("nav-env", "新环境名", "新建环境", "归档",
"refreshEnvs", "createEnv", "archiveEnv",
"/environments?project_id="):
self.assertIn(marker, html)
class FakeGit:
"""假 git:clone 在临时目录建 .git 并记 origin;fetch 恒成功;
checkout 只接受白名单分支,其余报分支不存在。"""
def __init__(self, allowed_branches=("main", "feature/x")):
self.allowed = set(allowed_branches)
self.origins = {}
self.commands = []
def __call__(self, cmd):
self.commands.append(list(cmd))
if cmd[1] == "clone":
target = cmd[3]
os.makedirs(os.path.join(target, ".git"), exist_ok=True)
self.origins[target] = cmd[2]
return SimpleNamespace(returncode=0, stdout="", stderr="")
if cmd[1] == "-C":
target = cmd[2]
if cmd[3] == "remote":
return SimpleNamespace(
returncode=0, stdout=self.origins.get(target, ""),
stderr="")
if cmd[3] == "fetch":
return SimpleNamespace(returncode=0, stdout="", stderr="")
if cmd[3] == "checkout":
branch = cmd[4]
if branch in self.allowed:
return SimpleNamespace(returncode=0, stdout="",
stderr="")
return SimpleNamespace(
returncode=1, stdout="",
stderr="pathspec '%s' did not match" % branch)
return SimpleNamespace(returncode=1, stdout="",
stderr="unexpected git command")
def _insert_env(db_path, env_id="env1", project_id="p1",
status="creating", branch=None, executor=None,
name="dev",
repo_url="https://github.com/octocat/Hello-World.git"):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO environments(id, project_id, name, branch,"
" executor, status, error_text, repo_url, created_by,"
" created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?,"
" NULL, ?, 'tester', ?, ?)",
(env_id, project_id, name, branch, executor, status,
repo_url, now, now))
conn.commit()
finally:
conn.close()
def _env_row(db_path, env_id):
conn = sqlite3.connect(db_path)
try:
row = conn.execute(
"SELECT * FROM environments WHERE id = ?",
(env_id,)).fetchone()
cols = [d[0] for d in conn.execute(
"SELECT * FROM environments LIMIT 0").description]
return dict(zip(cols, row)) if row else None
finally:
conn.close()
class WorkerEnvironmentsTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.repos_dir = os.path.join(self.tmp.name, "repos")
self.env = {"XCM_API_KEY": "test-placeholder"}
def tearDown(self):
self.tmp.cleanup()
def _worker(self, git=None, **kwargs):
kwargs.setdefault("env", self.env)
kwargs.setdefault("repos_dir", self.repos_dir)
kwargs.setdefault("repos_runner", git)
return Worker(self.db_path, "/fake/host.py", **kwargs)
def test_prepare_environment_ready(self):
_insert_env(self.db_path, branch="feature/x")
worker = self._worker(git=FakeGit())
results = worker._prepare_pending_environments()
self.assertEqual(results, [("env1", "ready", None)])
row = _env_row(self.db_path, "env1")
self.assertEqual(row["status"], "ready")
target = os.path.join(self.repos_dir, "envs", "env1")
self.assertTrue(os.path.isdir(os.path.join(target, ".git")))
def test_prepare_environment_failed_branch(self):
_insert_env(self.db_path, branch="no-such-branch")
worker = self._worker(git=FakeGit())
results = worker._prepare_pending_environments()
self.assertEqual(results[0][1], "failed")
self.assertIn("checkout", results[0][2])
self.assertEqual(_env_row(self.db_path, "env1")["status"],
"failed")
def test_prepare_environment_archived_during_prepare(self):
# 准备期间被归档:CAS 改不到行,状态保持 archived。
_insert_env(self.db_path)
worker = self._worker(git=FakeGit())
orig = worker._prepare_environment
def _prepare_and_archive(env):
err = orig(env)
_set_env_status(self.db_path, env["id"], "archived")
return err
worker._prepare_environment = _prepare_and_archive
results = worker._prepare_pending_environments()
self.assertEqual(results[0][1], "archived")
self.assertEqual(_env_row(self.db_path, "env1")["status"],
"archived")
def test_apply_environment_repo_local(self):
_insert_env(self.db_path, status="ready", executor="local")
worker = self._worker()
task = {"id": "T", "project_id": "p1", "environment_id": "env1",
"executor": "local"}
self.assertIsNone(worker._apply_project_repo(task))
self.assertEqual(
task["repo"],
os.path.join(self.repos_dir, "envs", "env1"))
def test_apply_environment_repo_tenant_docker(self):
_insert_env(self.db_path, status="ready", executor="docker")
worker = self._worker(
executor=DockerExecutor(config_path="/fake/cfg.json"),
tenant_isolation=True,
tenants_root=os.path.join(self.tmp.name, "tenants"))
task = {"id": "T", "project_id": "p1", "environment_id": "env1",
"executor": "docker"}
self.assertIsNone(worker._apply_project_repo(task))
self.assertEqual(task["repo"],
"/var/lib/codex-app-server/envs/env1")
def test_apply_environment_repo_rejections(self):
worker = self._worker()
# 环境不存在。
task = {"id": "T", "project_id": "p1", "environment_id": "nope"}
self.assertIn("not found", worker._apply_project_repo(task))
# 未 ready、跨项目、执行器冲突(同项目多环境须不同名,
# 部分唯一索引只拦同项目同名未归档行)。
_insert_env(self.db_path, env_id="e2", name="e2",
status="creating")
task = {"id": "T", "project_id": "p1", "environment_id": "e2"}
self.assertIn("not ready", worker._apply_project_repo(task))
_insert_env(self.db_path, env_id="e3", project_id="p2",
name="e3", status="ready")
task = {"id": "T", "project_id": "p1", "environment_id": "e3"}
self.assertIn("another project",
worker._apply_project_repo(task))
_insert_env(self.db_path, env_id="e4", name="e4",
status="ready", executor="docker")
task = {"id": "T", "project_id": "p1", "environment_id": "e4",
"executor": "local"}
self.assertIn("does not match",
worker._apply_project_repo(task))
def test_tenant_mounts_include_envs(self):
worker = self._worker(
executor=DockerExecutor(config_path="/fake/cfg.json"),
tenant_isolation=True,
tenants_root=os.path.join(self.tmp.name, "tenants"))
# 先播种租户目录(codex-home 模板缺失会报错,这里只测
# envs 挂载条件:手动建好租户目录结构再调 _tenant_mounts
# 的 envs 分支——目录不存在时不挂载,存在时挂载)。
base = os.path.join(self.tmp.name, "tenants", "p1")
os.makedirs(os.path.join(base, "work"), exist_ok=True)
os.makedirs(os.path.join(base, "codex-home"), exist_ok=True)
with open(os.path.join(base, "codex-home", "config.toml"),
"w") as f:
f.write("")
os.makedirs(os.path.join(base, "test-repo"), exist_ok=True)
with open(os.path.join(base, "test-repo", "sample.py"),
"w") as f:
f.write("")
mounts = worker._tenant_mounts("p1")
self.assertFalse(any("envs" in m for m in mounts))
os.makedirs(os.path.join(base, "envs", "env1"), exist_ok=True)
mounts = worker._tenant_mounts("p1")
self.assertTrue(any(
m.endswith(":/var/lib/codex-app-server/envs:rw")
for m in mounts))
if __name__ == "__main__":
unittest.main()