add: 补充回归测试(build 开关/资源档位、日志 fork、插件迁移与依赖清单、父进程死亡、worker 疑似失联)
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
migrate_plugin(内置插件 → 进程插件 一键迁移)测试
|
||||
|
||||
覆盖:
|
||||
1. 前置拦截: 自定义 FastAPI 路由(APIRouter 实例化 / 顶层 router 绑定 /
|
||||
star-import 再导出)拒绝迁移;--force 可跳过
|
||||
2. 前置拦截: 网关包导入(import app / from chongming...)拒绝迁移
|
||||
3. 放行: `from . import router`(子模块,非 APIRouter)不误伤(ip_sound_column 写法)
|
||||
4. 迁移产物: 项目骨架(pyproject/start.sh/config.toml/README/zenoh 配置)+ 代码/配置
|
||||
迁移 + [workers] 改写(module=workers.<name>、load_mode=external)+ 保留其余配置
|
||||
5. 依赖迁移: plugins_deps.toml [<name>] 节 → 新项目 pyproject(按实际 import 过滤、
|
||||
保留根 pyproject 的 extras 写法、共用节带入);[<name>] 节注释掉、其它节不动
|
||||
6. --dry-run 不落盘;目标已存在 / 插件不存在时拒绝
|
||||
|
||||
运行: uv run python test/test_migrate_plugin.py
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(ROOT / "utils" / "cli" / "src"))
|
||||
|
||||
os.environ.pop("CHONGMING_ROOT", None) # 根目录定位不应受外部环境变量影响
|
||||
|
||||
from chongming_cli import migrate_plugin as mp # noqa: E402
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f"[{status}] {name}" + (f" — {detail}" if detail else ""))
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
DEPS_TOML = """\
|
||||
imports = ["httpx"]
|
||||
|
||||
[foo]
|
||||
imports = [
|
||||
"volcenginesdkarkruntime",
|
||||
"chongming_cache",
|
||||
"orjson",
|
||||
"totally_missing_pkg",
|
||||
]
|
||||
|
||||
[bar]
|
||||
imports = ["openai"]
|
||||
"""
|
||||
|
||||
PLUGIN_INIT = '''"""fixture 插件"""
|
||||
import orjson
|
||||
import httpx
|
||||
from chongming_cache import Cache # noqa: F401
|
||||
from chongming_worker.plugin import Plugin
|
||||
from volcenginesdkarkruntime import AsyncArk # noqa: F401
|
||||
from totally_missing_pkg import thing # noqa: F401
|
||||
|
||||
from . import router # noqa: F401 触发 @rpc_method 注册
|
||||
{extra}
|
||||
|
||||
class FooPlugin(Plugin):
|
||||
type = "foo"
|
||||
db_name = None
|
||||
|
||||
async def on_init(self):
|
||||
pass
|
||||
|
||||
|
||||
plugin = FooPlugin()
|
||||
create_workers = plugin.create_workers
|
||||
cleanup = plugin.cleanup
|
||||
'''
|
||||
|
||||
CONFIG_TOML = """\
|
||||
# foo Worker 插件配置
|
||||
[workers]
|
||||
type = "foo"
|
||||
module = "plugins.foo"
|
||||
factory = "create_workers"
|
||||
cleanup = "cleanup"
|
||||
count = 2
|
||||
|
||||
[worker]
|
||||
name = "foo"
|
||||
version = "0.1.0"
|
||||
description = "fixture 插件"
|
||||
config_version = "1.0"
|
||||
defaults = { method = "POST", timeout = 30.0 }
|
||||
|
||||
[worker.registration]
|
||||
router_prefix = "/foo"
|
||||
|
||||
[workers.config]
|
||||
data_dir = "./data"
|
||||
api_key = "PLACEHOLDER"
|
||||
"""
|
||||
|
||||
|
||||
def make_root(tmp: Path) -> Path:
|
||||
"""最小仓库现场:根 pyproject(含 extras 依赖)/ config/zenoh_config.json5 副本 / plugins_deps.toml。"""
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'fixture'\ndependencies = [\"volcengine-python-sdk[ark]\"]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp / "config").mkdir(parents=True, exist_ok=True)
|
||||
(tmp / "config" / "zenoh_config.json5").write_text("{}\n", encoding="utf-8")
|
||||
(tmp / "plugins_deps.toml").write_text(DEPS_TOML, encoding="utf-8")
|
||||
return tmp
|
||||
|
||||
|
||||
def make_plugin(tmp: Path, name: str = "foo", init_extra: str = "", files: dict | None = None):
|
||||
"""造内置插件现场:src/plugins/<name>/ + config/workers/<name>/。"""
|
||||
code = tmp / "src" / "plugins" / name
|
||||
(code / "router").mkdir(parents=True, exist_ok=True)
|
||||
(code / "models").mkdir()
|
||||
(code / "models" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(code / "__init__.py").write_text(PLUGIN_INIT.format(extra=init_extra), encoding="utf-8")
|
||||
(code / "router" / "__init__.py").write_text("from . import hello # noqa: F401\n", encoding="utf-8")
|
||||
(code / "router" / "hello.py").write_text("async def hello():\n return {}\n", encoding="utf-8")
|
||||
conf = tmp / "config" / "workers" / name
|
||||
conf.mkdir(parents=True, exist_ok=True)
|
||||
(conf / "config.toml").write_text(CONFIG_TOML, encoding="utf-8")
|
||||
(conf / "hello.toml").write_text("[POST]\nsummary = \"hi\"\n", encoding="utf-8")
|
||||
for rel, text in (files or {}).items():
|
||||
path = code / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return tmp
|
||||
|
||||
|
||||
def run(tmp: Path, *args, name: str = "foo") -> tuple[int, str]:
|
||||
"""chdir 到现场执行迁移,捕获 stdout。"""
|
||||
buf = io.StringIO()
|
||||
cwd = Path.cwd()
|
||||
os.chdir(tmp)
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf):
|
||||
rc = mp.migrate_plugin_package(name, **kwargs_for(args))
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
return rc, buf.getvalue()
|
||||
|
||||
|
||||
def kwargs_for(args):
|
||||
kwargs = {}
|
||||
for a in args:
|
||||
kwargs[a] = True
|
||||
return kwargs
|
||||
|
||||
|
||||
ROUTER_FILES = {
|
||||
"routes.py": (
|
||||
"from fastapi import APIRouter\n\n"
|
||||
"router = APIRouter(prefix=\"/foo\")\n"
|
||||
),
|
||||
}
|
||||
|
||||
tmp_root = Path(tempfile.mkdtemp(prefix="migrate_plugin_test_"))
|
||||
|
||||
# ========== 1. 自定义路由拦截(aibox 写法) ==========
|
||||
case = make_root(tmp_root / "case_router")
|
||||
make_plugin(case, init_extra="from .routes import router # noqa: F401", files=ROUTER_FILES)
|
||||
rc, out = run(case)
|
||||
check("路由挂载(from .routes import router + APIRouter)拒绝迁移", rc == 1, f"rc={rc}")
|
||||
check("拒绝时给出路由提示", "APIRouter" in out and "拒绝迁移" in out)
|
||||
check("拒绝时不动任何文件", (case / "src" / "plugins" / "foo").is_dir() and not (case / "workers").exists())
|
||||
|
||||
# ========== 2. star-import 再导出的 APIRouter 同样拦截 ==========
|
||||
case = make_root(tmp_root / "case_star")
|
||||
make_plugin(
|
||||
case,
|
||||
init_extra="from .router import * # noqa: F401,F403",
|
||||
files={}, # router/ 由 make_plugin 建好,下面覆盖 __init__.py 为 APIRouter 定义
|
||||
)
|
||||
(case / "src" / "plugins" / "foo" / "router" / "__init__.py").write_text(
|
||||
"from fastapi import APIRouter\n\nrouter = APIRouter()\n", encoding="utf-8"
|
||||
)
|
||||
rc, out = run(case)
|
||||
check("star-import 链上的 APIRouter 拒绝迁移", rc == 1 and "APIRouter" in out, f"rc={rc}")
|
||||
|
||||
# ========== 3. --force 跳过拦截 ==========
|
||||
rc, out = run(case, "force")
|
||||
check("--force 跳过后可迁移", rc == 0 and (case / "workers" / "foo" / "pyproject.toml").is_file(), f"rc={rc}")
|
||||
check("--force 输出回显跳过", "跳过上述拦截" in out)
|
||||
check(
|
||||
"配置缺 module/load_mode 时自动补全",
|
||||
'module = "workers.foo"' in (case / "workers" / "foo" / "config" / "workers" / "foo" / "config.toml").read_text(),
|
||||
)
|
||||
|
||||
# ========== 4. 网关包导入拦截 ==========
|
||||
case = make_root(tmp_root / "case_gw")
|
||||
make_plugin(case, init_extra="from app.core.logger import get_logger # noqa: F401")
|
||||
rc, out = run(case)
|
||||
check("网关包导入拒绝迁移", rc == 1 and "网关" in out, f"rc={rc}")
|
||||
|
||||
# ========== 5. dry-run 不落盘 ==========
|
||||
case = make_root(tmp_root / "case_dry")
|
||||
make_plugin(case)
|
||||
before = (case / "plugins_deps.toml").read_text(encoding="utf-8")
|
||||
rc, out = run(case, "dry_run")
|
||||
check("--dry-run 返回成功且不落盘", rc == 0 and not (case / "workers").exists())
|
||||
check("--dry-run 不改 plugins_deps.toml", (case / "plugins_deps.toml").read_text(encoding="utf-8") == before)
|
||||
check("--dry-run 打印演练提示", "未改动任何文件" in out)
|
||||
|
||||
# ========== 6. 目标已存在 / 插件不存在 ==========
|
||||
case = make_root(tmp_root / "case_exists")
|
||||
make_plugin(case)
|
||||
(case / "workers" / "foo").mkdir(parents=True)
|
||||
rc, out = run(case)
|
||||
check("目标已存在拒绝迁移", rc == 1 and "目标已存在" in out)
|
||||
case = make_root(tmp_root / "case_missing")
|
||||
rc, out = run(case)
|
||||
check("插件不存在拒绝迁移", rc == 1 and "未找到内置插件" in out)
|
||||
|
||||
# ========== 7. 完整迁移(happy path) ==========
|
||||
case = make_root(tmp_root / "case_full")
|
||||
make_plugin(case)
|
||||
rc, out = run(case)
|
||||
project = case / "workers" / "foo"
|
||||
check("完整迁移成功", rc == 0 and project.is_dir(), f"rc={rc}")
|
||||
|
||||
code_dst = project / "workers" / "foo"
|
||||
conf_dst = project / "config" / "workers" / "foo"
|
||||
check(
|
||||
"代码迁移(含 router/ 与 models/)",
|
||||
(code_dst / "__init__.py").is_file()
|
||||
and (code_dst / "router" / "hello.py").is_file()
|
||||
and (code_dst / "models" / "__init__.py").is_file(),
|
||||
)
|
||||
check("路由配置迁移", (conf_dst / "hello.toml").is_file())
|
||||
conf_text = (conf_dst / "config.toml").read_text(encoding="utf-8")
|
||||
check(
|
||||
"[workers] 改写: module=workers.foo / load_mode=external / 其余保留",
|
||||
'module = "workers.foo"' in conf_text
|
||||
and 'load_mode = "external"' in conf_text
|
||||
and 'count = 2' in conf_text
|
||||
and 'api_key = "PLACEHOLDER"' in conf_text,
|
||||
)
|
||||
check("项目骨架生成", all((project / f).is_file() for f in ("config.toml", "pyproject.toml", "README.md", "start.sh")))
|
||||
check("zenoh 配置复制", (project / "config" / "zenoh_config.json5").is_file())
|
||||
check("start.sh 可执行且入口正确", (project / "start.sh").stat().st_mode & 0o111 and "chongming-worker --type foo" in (project / "start.sh").read_text())
|
||||
check("README 标注迁移来源", "migrate_plugin foo" in (project / "README.md").read_text())
|
||||
check("源目录已删除", not (case / "src" / "plugins" / "foo").exists() and not (case / "config" / "workers" / "foo").exists())
|
||||
|
||||
pyproject = tomllib.loads((project / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
deps = pyproject["project"]["dependencies"]
|
||||
check("新项目依赖: chongming-worker + 迁移带入", "chongming-worker" in deps and "httpx" in deps and "orjson" in deps and "chongming-cache" in deps, str(deps))
|
||||
check("extras 写法沿用根 pyproject", "volcengine-python-sdk[ark]" in deps, str(deps))
|
||||
check("未解析/未使用的依赖不进新项目", not any("totally_missing_pkg" in d for d in deps), str(deps))
|
||||
check("未解析依赖有告警", "totally_missing_pkg" in out)
|
||||
|
||||
deps_data = tomllib.loads((case / "plugins_deps.toml").read_text(encoding="utf-8"))
|
||||
check(
|
||||
"plugins_deps.toml: [foo] 节注释掉、共用节与其它节不动",
|
||||
"foo" not in deps_data and deps_data.get("imports") == ["httpx"] and deps_data.get("bar", {}).get("imports") == ["openai"],
|
||||
str(deps_data),
|
||||
)
|
||||
|
||||
# ========== 8. ip_sound_column 写法(from . import router 子模块)不误伤 ==========
|
||||
case = make_root(tmp_root / "case_submod")
|
||||
make_plugin(case, init_extra="from . import router # noqa: F401 触发 @rpc_method 注册")
|
||||
rc, out = run(case, "dry_run")
|
||||
check("from . import router(子模块)不误伤", rc == 0, f"rc={rc}")
|
||||
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
|
||||
print("\n" + ("=" * 40))
|
||||
if failures:
|
||||
print(f"❌ {len(failures)} 项未通过: {failures}")
|
||||
sys.exit(1)
|
||||
print("✅ 全部通过")
|
||||
Reference in New Issue
Block a user