#!/usr/bin/env python3 """ 根据PR改动文件选择需要运行的单元测试文件。 映射规则: 1. 改了tests/unit下的测试文件 -> 直接跑这些测试 2. 改了apps/api/app/api/routes/xxx.py -> 匹配 test_*xxx*.py 3. 改了apps/api/app/services/xxx.py -> 匹配 test_*xxx*.py 4. 改了apps/worker/.../xxx.py -> 匹配 test_*xxx*.py 5. 改了apps/worker/video_processing/xxx_engine.py -> 匹配 test_*xxx*.py 6. 改了packages/.../xxx.py -> 匹配 test_*xxx*.py 7. 改了公共核心模块(core/middleware/schemas/config/db/auth/dependencies) -> 全量 8. 改了依赖文件(requirements*.txt, pyproject.toml) -> 全量 9. 改了alembic/migrations -> 全量 10. 匹配不到测试的改动 -> 全量兜底 """ import os import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TESTS_DIR = ROOT / "tests" / "unit" # 触发全量的文件模式(公共核心/基础设施) FULL_RUN_PATTERNS = [ "apps/api/app/core/", "apps/api/app/middleware/", "apps/api/app/schemas/", "apps/api/app/config.py", "apps/api/app/db.py", "apps/api/app/auth.py", "apps/api/app/dependencies.py", "packages/shared/", "alembic/", "migrations/", "requirements-base.txt", "requirements.txt", "requirements-dev.txt", "pyproject.toml", "setup.cfg", ".gitea/workflows/", "scripts/ci/", "tests/conftest.py", ] # 目录到测试文件关键词的映射(模糊匹配) DIR_KEYWORD_MAP = { "apps/api/app/api/routes/": "", # 用文件名匹配 "apps/api/app/services/": "", # 用文件名匹配 "apps/worker/worker_app/tasks/": "", "apps/worker/video_processing/": "", "apps/worker/services/": "", "packages/application/": "", "packages/adapters/": "", } def get_changed_files(): """获取改动文件列表(从环境变量或git diff)。""" # 优先从环境变量读取(CI中传入) changed_env = os.environ.get("CHANGED_FILES", "") if changed_env: return [f.strip() for f in changed_env.split("\n") if f.strip()] # 回退到git diff(本地调试用) try: result = subprocess.run( ["git", "diff", "--name-only", "origin/develop...HEAD"], capture_output=True, text=True, cwd=ROOT, ) if result.returncode == 0: return [f.strip() for f in result.stdout.split("\n") if f.strip()] except Exception: pass return [] def should_full_run(files): """检查是否需要全量运行。""" for f in files: for pattern in FULL_RUN_PATTERNS: if f.startswith(pattern) or f == pattern: print(f"[full-run] 触发全量: {f} 匹配 {pattern}") return True return False def extract_module_name(filepath): """从文件路径提取模块名(用于匹配测试文件)。""" # 去掉扩展名 name = Path(filepath).stem # 特殊映射 special_mappings = { # 路由文件 "edit_plans_adjustments": "edit_plan_adjustments", "edit_plans_ai": "edit_plan", "edit_plans_clips_batch": "edit_plan", "edit_plans_cover": "edit_plan_cover", "edit_plans_export": "edit_plan_export", "edit_plans_filter": "edit_plan_filter", "edit_plans_generation": "edit_plan_generation", "edit_plans_transitions": "edit_plan_transitions", "asset_libraries": "asset_library", "classification_jobs": "classification", "chunked_upload": "chunked_upload", "form_upload": "form_upload", # 服务文件 "edit_template_service": "edit_template_service", "edit_plan_service": "edit_plan_service", "unified_render_service": "unified_render", "job_service": "job_service", "auto_clip_service": "auto_clip", "cosyvoice_service": "cosyvoice", "video_compose_service": "video_compose", "email_service": "email_service", } return special_mappings.get(name, name) def find_matching_tests(keyword, all_test_files): """模糊匹配测试文件。""" keyword_lower = keyword.lower().replace("_", "") matches = [] for tf in all_test_files: tf_name = Path(tf).stem.lower().replace("_", "") if keyword_lower in tf_name or tf_name in keyword_lower: matches.append(tf) return matches def get_all_test_files(): """获取所有单元测试文件。""" if not TESTS_DIR.exists(): return [] return sorted(str(f.relative_to(ROOT)) for f in TESTS_DIR.glob("test_*.py")) def select_tests(changed_files): """主函数:选择要运行的测试文件。""" all_tests = get_all_test_files() if not changed_files: print("[info] 未找到改动文件,全量运行") return all_tests, "full (no changes detected)" if should_full_run(changed_files): return all_tests, "full (core/common files changed)" selected = set() test_file_changes = [] source_file_changes = [] for f in changed_files: # 测试文件本身改动(仅保留仍存在的文件,删除的测试文件不加入运行列表) if f.startswith("tests/unit/test_") and f.endswith(".py"): if (ROOT / f).exists(): test_file_changes.append(f) selected.add(f) else: print(f"[skip-deleted] 测试文件已删除,跳过: {f}") # 源码文件改动 elif f.endswith(".py"): source_file_changes.append(f) module_name = extract_module_name(f) matches = find_matching_tests(module_name, all_tests) if matches: for m in matches: selected.add(m) print(f"[map] {f} -> {len(matches)} 个测试: {[Path(m).name for m in matches]}") else: print(f"[nomatch] {f} (module: {module_name}) 未找到匹配的测试文件") if not selected: print("[info] 未匹配到任何测试文件,全量运行兜底") return all_tests, "full (no matching tests)" return sorted(selected), f"incremental ({len(selected)} test files)" def main(): changed_files = get_changed_files() print(f"=== 改动文件 ({len(changed_files)} 个) ===") for f in changed_files[:20]: print(f" {f}") if len(changed_files) > 20: print(f" ... 还有 {len(changed_files) - 20} 个") print() selected, mode = select_tests(changed_files) print() print(f"=== 运行模式: {mode} ===") print(f"=== 选中测试文件: {len(selected)} 个 ===") for t in selected[:20]: print(f" {t}") if len(selected) > 20: print(f" ... 还有 {len(selected) - 20} 个") # 输出结果文件(供CI后续步骤使用) output_file = os.environ.get("SELECTED_TESTS_OUTPUT", "") if output_file: with open(output_file, "w") as f: for t in selected: f.write(t + "\n") print(f"\n已写入到: {output_file}") # 设置环境变量标记 gh_output = os.environ.get("GITHUB_OUTPUT", "") if gh_output: with open(gh_output, "a") as f: f.write(f"test_count={len(selected)}\n") f.write("mode=" + ("incremental" if "incremental" in mode else "full") + "\n") # 退出码:0=增量, 1=全量(供CI判断) sys.exit(0 if "incremental" in mode else 1) if __name__ == "__main__": main()