import importlib.util import json import sys from argparse import Namespace from pathlib import Path from typing import Any, Optional def load_publish_plugin_module() -> Any: """加载插件发布脚本模块。""" script_path = ( Path(__file__).resolve().parents[1] / "skills" / "publish-moviepilot-plugin" / "scripts" / "publish_plugin.py" ) spec = importlib.util.spec_from_file_location("publish_plugin_skill", script_path) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module def test_collect_local_files_excludes_secrets_and_keeps_dist(tmp_path: Path) -> None: """收集本地插件文件时应排除敏感文件并保留前端构建产物。""" module = load_publish_plugin_module() plugin_dir = tmp_path / "plugins.v2" / "myplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("class MyPlugin:\n pass\n", encoding="utf-8") (plugin_dir / ".env").write_text("SECRET=1\n", encoding="utf-8") (plugin_dir / "dist" / "assets").mkdir(parents=True) (plugin_dir / "dist" / "assets" / "remoteEntry.js").write_text( "export default {};\n", encoding="utf-8", ) layout = module.Layout(package_file="package.v2.json", plugin_root="plugins.v2") files, rejected = module.collect_local_files( tmp_path, layout, "MyPlugin", list(module.DEFAULT_EXCLUDES), [], ) assert "plugins.v2/myplugin/__init__.py" in files assert "plugins.v2/myplugin/dist/assets/remoteEntry.js" in files assert rejected == {"plugins.v2/myplugin/.env": ".env"} def test_merge_package_content_preserves_other_plugins() -> None: """合并 package 文件时只更新目标插件条目。""" module = load_publish_plugin_module() remote_content = json.dumps( { "OtherPlugin": {"name": "其他插件", "version": "1.0.0"}, "MyPlugin": {"name": "旧插件", "version": "0.9.0"}, }, ensure_ascii=False, ).encode("utf-8") merged = module.merge_package_content( remote_content, "MyPlugin", {"name": "新插件", "version": "1.0.0"}, ) package_data = json.loads(merged.decode("utf-8")) assert package_data["OtherPlugin"] == {"name": "其他插件", "version": "1.0.0"} assert package_data["MyPlugin"] == {"name": "新插件", "version": "1.0.0"} def test_push_creates_public_repo_when_explicitly_requested(tmp_path: Path) -> None: """显式允许自动建仓时,推送会默认创建公开仓库。""" module = load_publish_plugin_module() plugin_dir = tmp_path / "plugins.v2" / "myplugin" plugin_dir.mkdir(parents=True) (plugin_dir / "__init__.py").write_text("class MyPlugin:\n pass\n", encoding="utf-8") (tmp_path / "package.v2.json").write_text( json.dumps({"MyPlugin": {"name": "测试插件", "version": "1.0.0"}}, ensure_ascii=False), encoding="utf-8", ) class FakeClient: """模拟缺失仓库的 GitHub 客户端。""" def __init__(self) -> None: """初始化调用记录。""" self.created_private = None self.put_paths: list[str] = [] def repo_exists(self) -> bool: """返回仓库不存在。""" return False def create_repo(self, private: bool = False) -> dict[str, Any]: """记录建仓可见性。""" self.created_private = private return {"private": private, "html_url": "https://github.com/example/repo"} def put_file( self, path: str, content: bytes, message: str, sha: Optional[str] = None, ) -> dict[str, Any]: """记录上传路径。""" self.put_paths.append(path) return {"content": {"path": path}} def delete_file(self, path: str, message: str, sha: str) -> dict[str, Any]: """记录删除路径。""" return {"content": {"path": path}} fake_client = FakeClient() def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]: """构造无需访问网络的推送上下文。""" layout = module.Layout(package_file="package.v2.json", plugin_root="plugins.v2") return { "repo": args.repo, "branch": args.branch, "local_repo": tmp_path, "layout": layout, "plugin_id": args.plugin_id, "remote_prefix": module.remote_plugin_prefix(layout, args.plugin_id), "client": fake_client, "excludes": list(module.DEFAULT_EXCLUDES), "includes": [], } original_build_context = module.build_context module.build_context = fake_build_context try: payload = module.push( Namespace( command="push", repo="example/repo", plugin_id="MyPlugin", local_repo=str(tmp_path), package_version="v2", branch="main", token="token", message="Publish MyPlugin", api_base=module.GITHUB_API_BASE, proxy="", timeout=module.DEFAULT_TIMEOUT, include=[], exclude=[], delete_remote=False, create_repo_if_missing=True, private=False, force=False, dry_run=False, ) ) finally: module.build_context = original_build_context assert payload["repo_existed"] is False assert fake_client.created_private is False assert "package.v2.json" in fake_client.put_paths assert "plugins.v2/myplugin/__init__.py" in fake_client.put_paths def test_create_repo_dry_run_does_not_touch_github() -> None: """建仓 dry-run 不访问 GitHub,只返回计划。""" module = load_publish_plugin_module() class FakeClient: """模拟不应被调用的 GitHub 客户端。""" def repo_exists(self) -> bool: """如果被调用则说明 dry-run 行为错误。""" raise AssertionError("dry-run should not query GitHub") def create_repo(self, private: bool = False) -> dict[str, Any]: """如果被调用则说明 dry-run 行为错误。""" raise AssertionError("dry-run should not create GitHub repo") def fake_build_context(args: Namespace, require_token: bool = False) -> dict[str, Any]: """构造无需访问网络的建仓上下文。""" return { "repo": args.repo, "branch": args.branch, "local_repo": Path.cwd(), "layout": module.Layout(package_file="package.v2.json", plugin_root="plugins.v2"), "plugin_id": "", "remote_prefix": "", "client": FakeClient(), "excludes": list(module.DEFAULT_EXCLUDES), "includes": [], } original_build_context = module.build_context module.build_context = fake_build_context try: payload = module.create_repo( Namespace( command="create-repo", repo="example/repo", plugin_id="", local_repo="", package_version="auto", branch="main", token="", message="", api_base=module.GITHUB_API_BASE, proxy="", timeout=module.DEFAULT_TIMEOUT, include=[], exclude=[], delete_remote=False, create_repo_if_missing=False, private=False, force=False, dry_run=True, ) ) finally: module.build_context = original_build_context assert payload["success"] is True assert payload["dry_run"] is True assert payload["private"] is False