diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2090a85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,148 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + lint: + name: 代码检查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 安装 Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 安装依赖 + run: pip install ruff + + - name: 语法检查 (py_compile) + run: | + python -c " + import py_compile, os, sys + errors = 0 + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', 'node_modules', 'embedded_python'}] + for f in files: + if f.endswith('.py'): + path = os.path.join(root, f) + try: + py_compile.compile(path, doraise=True) + except py_compile.PyCompileError as e: + print(f'ERROR: {e}') + errors += 1 + if errors: + print(f'\n{errors} 个文件有语法错误') + sys.exit(1) + print('所有文件语法正确') + " + + - name: Ruff lint + run: ruff check . --select E,F,W --ignore E501,E402,W503 --exclude .git,__pycache__,embedded_python + + test: + name: 插件测试 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: 安装 Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: 安装测试依赖 + run: pip install pytest colorama + + - name: 运行测试 + run: pytest tests/ -v --tb=short + + structure: + name: 项目结构检查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 安装 Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: 检查必要文件 + run: | + echo "检查项目结构..." + files=( + "ai_cli.py" + "launcher.py" + "xcli_core/cli_core.py" + "xcli_core/cli_base.py" + "xcli_core/config.py" + "xcli_core/constants.py" + "ai_engines/ollama_engine.py" + "ai_engines/openai_engine.py" + "plugins/code_editor.py" + "plugins/git_tools.py" + "plugins/cmd_executor.py" + "plugins/file_manager.py" + "plugins/auto_engineer.py" + ) + missing=0 + for f in "${files[@]}"; do + if [ -f "$f" ]; then + echo " ✓ $f" + else + echo " ✗ $f (缺失!)" + missing=$((missing + 1)) + fi + done + if [ $missing -gt 0 ]; then + echo "\n${missing} 个文件缺失" + exit 1 + fi + echo "\n项目结构完整" + + - name: 检查旧文件已清理 + run: | + old_files=( + "ai_engines/mimo_engine.py" + "ai_engines/GLM_http_engine.py" + "ai_engines/qwq_engine.py" + "ai_engines/xfyun_spark_engine.py" + "ai_engines/iflow_api_engine.py" + "ai_engines/maas_http_engine.py" + "ai_engines/SDK_openAI.py" + "plugins/git_workflow.py" + "plugins/code_search.py" + "plugins/project_analyzer.py" + "plugins/scheduler.py" + ) + found=0 + for f in "${old_files[@]}"; do + if [ -f "$f" ]; then + echo " ⚠️ 旧文件未清理: $f" + found=$((found + 1)) + fi + done + if [ $found -gt 0 ]; then + echo "\n${found} 个旧文件未清理" + exit 1 + fi + echo "旧文件已全部清理" + + - name: 检查无重复引擎 + run: | + engines=$(ls ai_engines/*_engine.py 2>/dev/null | wc -l) + echo "AI 引擎数量: $engines" + if [ "$engines" -gt 2 ]; then + echo "警告: 引擎数量超过 2 个 (ollama + openai)" + exit 1 + fi + echo "引擎数量正常" diff --git a/plugins/code_editor.py b/plugins/code_editor.py index 7f2cedf..d93367c 100644 --- a/plugins/code_editor.py +++ b/plugins/code_editor.py @@ -88,13 +88,25 @@ class Plugin: def handle(self, args: str) -> str: try: - parts = args.strip().split(maxsplit=2) - if not parts: - return "错误:请提供操作类型" + # 先提取操作名 + first_space = args.strip().find(' ') + if first_space == -1: + operation = args.strip().lower() + arg1, arg2 = "", "" + else: + operation = args.strip()[:first_space].lower() + rest = args.strip()[first_space + 1:] - operation = parts[0].lower() - arg1 = parts[1] if len(parts) > 1 else "" - arg2 = parts[2] if len(parts) > 2 else "" + # diff_text 特殊处理:用 <<<>>> 分割 + if operation == "diff_text" and " <<<>>> " in rest: + arg1, arg2 = rest.split(" <<<>>> ", 1) + else: + parts = rest.split(maxsplit=1) + arg1 = parts[0] if parts else "" + arg2 = parts[1] if len(parts) > 1 else "" + + if not operation: + return "错误:请提供操作类型" handlers = { # 编辑 @@ -384,11 +396,9 @@ class Plugin: diff = '\n'.join(difflib.unified_diff(l1, l2, fromfile=f1, tofile=f2, lineterm='')) return diff or "两个文件完全相同" - def _op_diff_text(self, path: str, rest: str) -> str: - sep = " <<<>>> " - if sep not in rest: + def _op_diff_text(self, old: str, new: str) -> str: + if not old and not new: return "错误:格式: diff_text <<<>>> " - old, new = rest.split(sep, 1) return self._make_diff(old.strip(), new.strip()) def _op_ast_info(self, path: str, rest: str) -> str: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8baff0c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 0000000..8c01ea3 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,393 @@ +"""插件加载与基础功能测试""" +import importlib.util +import os +import sys +import pytest + +# 项目根目录 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGINS_DIR = os.path.join(PROJECT_ROOT, "plugins") +ENGINES_DIR = os.path.join(PROJECT_ROOT, "ai_engines") + + +def discover_plugins(): + """发现所有插件文件""" + plugins = [] + if not os.path.isdir(PLUGINS_DIR): + return plugins + for f in sorted(os.listdir(PLUGINS_DIR)): + if f.endswith(".py") and not f.startswith("__"): + plugins.append(f[:-3]) + return plugins + + +def load_module(name, path): + """动态加载模块""" + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ══════════════════════════════════════ +# 插件加载测试 +# ══════════════════════════════════════ + +class TestPluginLoading: + """测试所有插件能否正常加载""" + + @pytest.mark.parametrize("plugin_name", discover_plugins()) + def test_plugin_imports(self, plugin_name): + """插件文件能被正常导入""" + path = os.path.join(PLUGINS_DIR, f"{plugin_name}.py") + mod = load_module(plugin_name, path) + assert mod is not None + + @pytest.mark.parametrize("plugin_name", discover_plugins()) + def test_plugin_has_class(self, plugin_name): + """插件包含 Plugin 或 Liugin 类""" + path = os.path.join(PLUGINS_DIR, f"{plugin_name}.py") + mod = load_module(plugin_name, path) + cls = getattr(mod, "Plugin", None) or getattr(mod, "Liugin", None) + assert cls is not None, f"{plugin_name}: 缺少 Plugin/Liugin 类" + + @pytest.mark.parametrize("plugin_name", discover_plugins()) + def test_plugin_has_required_methods(self, plugin_name): + """插件类包含必要的方法""" + path = os.path.join(PLUGINS_DIR, f"{plugin_name}.py") + mod = load_module(plugin_name, path) + cls = getattr(mod, "Plugin", None) or getattr(mod, "Liugin", None) + inst = cls() + + assert hasattr(inst, "handle"), f"{plugin_name}: 缺少 handle 方法" + assert hasattr(inst, "get_tool_info"), f"{plugin_name}: 缺少 get_tool_info 方法" + assert hasattr(inst, "set_cli"), f"{plugin_name}: 缺少 set_cli 方法" + + @pytest.mark.parametrize("plugin_name", discover_plugins()) + def test_plugin_get_tool_info(self, plugin_name): + """get_tool_info 返回正确的结构""" + path = os.path.join(PLUGINS_DIR, f"{plugin_name}.py") + mod = load_module(plugin_name, path) + cls = getattr(mod, "Plugin", None) or getattr(mod, "Liugin", None) + inst = cls() + info = inst.get_tool_info() + + assert isinstance(info, dict) + assert "name" in info, f"{plugin_name}: tool_info 缺少 name" + assert "description" in info, f"{plugin_name}: tool_info 缺少 description" + assert "keywords" in info, f"{plugin_name}: tool_info 缺少 keywords" + assert isinstance(info["keywords"], list) + + +# ══════════════════════════════════════ +# 插件功能测试 +# ══════════════════════════════════════ + +class TestCodeEditor: + """code_editor 功能测试""" + + @pytest.fixture + def editor(self): + mod = load_module("code_editor", os.path.join(PLUGINS_DIR, "code_editor.py")) + return mod.Plugin() + + @pytest.fixture + def tmp_file(self, tmp_path): + f = tmp_path / "test.py" + f.write_text("def hello():\n print('hello')\n\ndef world():\n print('world')\n") + return str(f) + + def test_read_range(self, editor, tmp_file): + result = editor.handle(f"read_range {tmp_file} 1 2") + assert "hello" in result + assert "1 |" in result + + def test_edit(self, editor, tmp_file): + result = editor.handle(f"edit {tmp_file} print('hello') <<<>>> print('hi')") + assert "已编辑" in result + content = open(tmp_file).read() + assert "print('hi')" in content + + def test_edit_not_found(self, editor, tmp_file): + result = editor.handle(f"edit {tmp_file} nonexistent_code <<<>>> new_code") + assert "错误" in result + + def test_create(self, editor, tmp_path): + new_file = str(tmp_path / "new.py") + result = editor.handle(f"create {new_file} import os") + assert "已创建" in result + assert os.path.exists(new_file) + + def test_diff_text(self, editor): + result = editor.handle("diff_text old <<<>>> new") + assert "变更预览" in result + + def test_ast_info(self, editor, tmp_file): + result = editor.handle(f"ast_info {tmp_file}") + assert "def hello" in result + assert "def world" in result + + def test_stats(self, editor, tmp_path): + result = editor.handle(f"stats {tmp_path}") + assert "代码统计" in result + assert "文件" in result + + def test_structure(self, editor, tmp_path): + result = editor.handle(f"structure {tmp_path} 1") + assert "📁" in result + + def test_find(self, editor, tmp_path): + (tmp_path / "a.py").write_text("x = 1\ny = 2\n") + result = editor.handle(f"find {tmp_path} x *.py") + assert "x = 1" in result + + +class TestGitTools: + """git_tools 功能测试""" + + @pytest.fixture + def git(self): + mod = load_module("git_tools", os.path.join(PLUGINS_DIR, "git_tools.py")) + return mod.Plugin() + + def test_status(self, git): + result = git.handle("status") + assert "Git" in result or "干净" in result or "分支" in result + + def test_log(self, git): + result = git.handle("log --oneline 3") + assert isinstance(result, str) + assert len(result) > 0 + + def test_branch(self, git): + result = git.handle("branch") + assert "master" in result or "main" in result + + +class TestCmdExecutor: + """cmd_executor 功能测试""" + + @pytest.fixture + def cmd(self): + mod = load_module("cmd_executor", os.path.join(PLUGINS_DIR, "cmd_executor.py")) + return mod.Plugin() + + def test_echo(self, cmd): + result = cmd.handle("run echo test_ok") + assert "test_ok" in result + + def test_python_version(self, cmd): + result = cmd.handle("run python3 --version") + assert "Python" in result + + def test_blocked_command(self, cmd): + result = cmd.handle("run rm -rf /") + assert "拦截" in result + + def test_timeout(self, cmd): + result = cmd.handle("run echo fast --timeout 5") + assert "fast" in result + + def test_empty_command(self, cmd): + result = cmd.handle("run") + assert "错误" in result + + +class TestFileManager: + """file_manager 功能测试""" + + @pytest.fixture + def fm(self): + mod = load_module("file_manager", os.path.join(PLUGINS_DIR, "file_manager.py")) + return mod.Plugin() + + def test_list(self, fm, tmp_path): + (tmp_path / "a.txt").write_text("hi") + result = fm.handle(f"list {tmp_path}") + assert "a.txt" in result + + def test_read(self, fm, tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello world") + result = fm.handle(f"read {f}") + assert "hello world" in result + + def test_write_and_read(self, fm, tmp_path): + f = tmp_path / "new.txt" + result = fm.handle(f"write {f} -c test_content") + assert "已写入" in result + result = fm.handle(f"read {f}") + assert "test_content" in result + + def test_copy(self, fm, tmp_path): + src = tmp_path / "src.txt" + src.write_text("data") + dst = tmp_path / "dst.txt" + result = fm.handle(f"copy {src} {dst}") + assert "已复制" in result + assert dst.exists() + + def test_delete(self, fm, tmp_path): + f = tmp_path / "del.txt" + f.write_text("bye") + result = fm.handle(f"delete {f}") + assert "已删除" in result + assert not f.exists() + + def test_mkdir(self, fm, tmp_path): + d = tmp_path / "newdir" + result = fm.handle(f"mkdir {d}") + assert "已创建" in result + assert d.is_dir() + + def test_info(self, fm, tmp_path): + f = tmp_path / "info.txt" + f.write_text("x") + result = fm.handle(f"info {f}") + assert "路径" in result + assert "大小" in result + + +class TestAutoEngineer: + """auto_engineer 功能测试""" + + @pytest.fixture + def eng(self): + mod = load_module("auto_engineer", os.path.join(PLUGINS_DIR, "auto_engineer.py")) + return mod.Plugin() + + def test_metrics(self, eng, tmp_path): + (tmp_path / "a.py").write_text("x = 1\n# comment\n\ny = 2\n") + result = eng.handle(f"metrics {tmp_path}") + assert "代码指标" in result + assert "文件" in result + + def test_security_clean(self, eng, tmp_path): + (tmp_path / "safe.py").write_text("x = 1\nprint(x)\n") + result = eng.handle(f"security {tmp_path}") + assert "没有发现" in result or "风险" in result + + def test_security_risk(self, eng, tmp_path): + (tmp_path / "risk.py").write_text('password = "secret123"\neval("1+1")\n') + result = eng.handle(f"security {tmp_path}") + assert "风险" in result + + def test_complexity(self, eng, tmp_path): + code = "def f():\n if True:\n if True:\n if True:\n pass\n" + (tmp_path / "complex.py").write_text(code) + result = eng.handle(f"complexity {tmp_path}") + assert isinstance(result, str) + + +class TestTaskManager: + """task_manager 功能测试""" + + @pytest.fixture + def tm(self, tmp_path, monkeypatch): + mod = load_module("task_manager", os.path.join(PLUGINS_DIR, "task_manager.py")) + inst = getattr(mod, "Liugin")() + inst.tasks = [] + inst.tasks_file = str(tmp_path / "tasks.json") + return inst + + def test_add(self, tm): + result = tm.handle("add 测试任务") + assert "已添加" in result + + def test_list_empty(self, tm): + result = tm.handle("list") + assert "没有任务" in result + + def test_add_and_list(self, tm): + tm.handle("add 任务A") + tm.handle("add 任务B") + result = tm.handle("list") + assert "任务A" in result + assert "任务B" in result + + def test_complete(self, tm): + tm.handle("add 任务X") + result = tm.handle("complete 1") + assert "已更新" in result + + def test_delete(self, tm): + tm.handle("add 任务Y") + result = tm.handle("delete 1") + assert "已删除" in result + + def test_clear(self, tm): + tm.handle("add A") + tm.handle("complete 1") + result = tm.handle("clear") + assert "已清除" in result + + +# ══════════════════════════════════════ +# 引擎加载测试 +# ══════════════════════════════════════ + +class TestEngines: + """AI 引擎加载测试""" + + def test_engines_dir_exists(self): + assert os.path.isdir(ENGINES_DIR), "ai_engines 目录不存在" + + def test_ollama_engine_loads(self): + path = os.path.join(ENGINES_DIR, "ollama_engine.py") + if not os.path.exists(path): + pytest.skip("ollama_engine.py 不存在") + mod = load_module("ollama_engine", path) + cls = getattr(mod, "OllamaAI", None) + assert cls is not None, "缺少 OllamaAI 类" + inst = cls() + assert inst.name == "ollama" + + def test_openai_engine_loads(self): + path = os.path.join(ENGINES_DIR, "openai_engine.py") + if not os.path.exists(path): + pytest.skip("openai_engine.py 不存在") + mod = load_module("openai_engine", path) + cls = getattr(mod, "OpenaiAI", None) + assert cls is not None, "缺少 OpenaiAI 类" + inst = cls() + assert inst.name == "openai" + + def test_no_old_engines(self): + """确认旧引擎文件已被移除""" + old_engines = [ + "mimo_engine.py", "GLM_http_engine.py", "qwq_engine.py", + "xfyun_spark_engine.py", "iflow_api_engine.py", + "maas_http_engine.py", "SDK_openAI.py", + ] + for name in old_engines: + path = os.path.join(ENGINES_DIR, name) + assert not os.path.exists(path), f"旧引擎未删除: {name}" + + +# ══════════════════════════════════════ +# 核心模块导入测试 +# ══════════════════════════════════════ + +class TestCoreImports: + """核心模块能否正常导入""" + + def test_import_config(self): + sys.path.insert(0, os.path.join(PROJECT_ROOT, "xcli_core")) + from config import load_config, save_config + assert callable(load_config) + + def test_import_constants(self): + sys.path.insert(0, os.path.join(PROJECT_ROOT, "xcli_core")) + import constants + assert hasattr(constants, "DEFAULT_MAX_HISTORY") + + def test_import_cli_base(self): + sys.path.insert(0, PROJECT_ROOT) + sys.path.insert(0, os.path.join(PROJECT_ROOT, "xcli_core")) + # 只测试语法,不实例化(需要完整环境) + spec = importlib.util.spec_from_file_location( + "cli_base", os.path.join(PROJECT_ROOT, "xcli_core", "cli_base.py") + ) + mod = importlib.util.module_from_spec(spec) + # 不执行,只检查语法 + assert spec is not None