| 1 | import subprocess |
| 2 | import sys |
| 3 | from pathlib import Path |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 8 | if str(PROJECT_ROOT) not in sys.path: |
| 9 | sys.path.insert(0, str(PROJECT_ROOT)) |
| 10 | |
| 11 | from helpers import git as git_helpers |
| 12 | from plugins._plugin_installer.helpers import install |
| 13 | |
| 14 | |
| 15 | def run_git(repo: Path, *args: str) -> str: |
| 16 | return subprocess.run( |
| 17 | ["git", "-C", str(repo), *args], |
| 18 | check=True, |
| 19 | capture_output=True, |
| 20 | text=True, |
| 21 | ).stdout.strip() |
| 22 | |
| 23 | |
| 24 | def git_status(repo: Path) -> str: |
| 25 | return subprocess.run( |
| 26 | ["git", "-C", str(repo), "status", "--porcelain"], |
| 27 | check=True, |
| 28 | capture_output=True, |
| 29 | text=True, |
| 30 | ).stdout.rstrip() |
| 31 | |
| 32 | |
| 33 | def make_plugin_repos(tmp_path: Path) -> tuple[Path, Path, Path]: |
| 34 | remote = tmp_path / "remote.git" |
| 35 | source = tmp_path / "source" |
| 36 | installed = tmp_path / "installed" |
| 37 | subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) |
| 38 | subprocess.run(["git", "init", str(source)], check=True, capture_output=True) |
| 39 | run_git(source, "config", "user.email", "tests@example.com") |
| 40 | run_git(source, "config", "user.name", "Tests") |
| 41 | (source / "plugin.py").write_text("value = 'old'\n", encoding="utf-8") |
| 42 | (source / "README.md").write_text("old\n", encoding="utf-8") |
| 43 | run_git(source, "add", ".") |
| 44 | run_git(source, "commit", "-m", "initial") |
| 45 | run_git(source, "branch", "-M", "main") |
| 46 | run_git(source, "remote", "add", "origin", str(remote)) |
| 47 | run_git(source, "push", "-u", "origin", "main") |
| 48 | subprocess.run( |
| 49 | ["git", "-C", str(remote), "symbolic-ref", "HEAD", "refs/heads/main"], |
| 50 | check=True, |
| 51 | capture_output=True, |
| 52 | ) |
| 53 | subprocess.run(["git", "clone", str(remote), str(installed)], check=True, capture_output=True) |
| 54 | run_git(installed, "config", "user.email", "tests@example.com") |
| 55 | run_git(installed, "config", "user.name", "Tests") |
| 56 | return remote, source, installed |
| 57 | |
| 58 | |
| 59 | def push_source_change(source: Path, path: str, content: str) -> None: |
| 60 | (source / path).write_text(content, encoding="utf-8") |
| 61 | run_git(source, "add", path) |
| 62 | run_git(source, "commit", "-m", f"update {path}") |
| 63 | run_git(source, "push") |
| 64 | |
| 65 | |
| 66 | def test_update_repo_preserves_non_conflicting_tracked_and_untracked_files(tmp_path: Path): |
| 67 | _, source, installed = make_plugin_repos(tmp_path) |
| 68 | original_head = run_git(installed, "rev-parse", "HEAD") |
| 69 | (installed / "README.md").write_text("local edit\n", encoding="utf-8") |
| 70 | (installed / ".toggle-1").write_text("enabled\n", encoding="utf-8") |
| 71 | push_source_change(source, "plugin.py", "value = 'upstream'\n") |
| 72 | |
| 73 | git_helpers.update_repo(str(installed)) |
| 74 | |
| 75 | assert run_git(installed, "rev-parse", "HEAD") != original_head |
| 76 | assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'upstream'\n" |
| 77 | assert (installed / "README.md").read_text(encoding="utf-8") == "local edit\n" |
| 78 | assert (installed / ".toggle-1").read_text(encoding="utf-8") == "enabled\n" |
| 79 | assert run_git(installed, "stash", "list") == "" |
| 80 | |
| 81 | |
| 82 | def test_update_repo_drops_local_edit_that_matches_the_new_upstream_version(tmp_path: Path): |
| 83 | _, source, installed = make_plugin_repos(tmp_path) |
| 84 | (installed / "plugin.py").write_text("value = 'upstream'\n", encoding="utf-8") |
| 85 | push_source_change(source, "plugin.py", "value = 'upstream'\n") |
| 86 | |
| 87 | git_helpers.update_repo(str(installed)) |
| 88 | |
| 89 | assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'upstream'\n" |
| 90 | assert git_status(installed) == "" |
| 91 | assert run_git(installed, "stash", "list") == "" |
| 92 | |
| 93 | |
| 94 | def test_update_repo_restores_original_plugin_and_local_edit_after_conflict(tmp_path: Path): |
| 95 | _, source, installed = make_plugin_repos(tmp_path) |
| 96 | original_head = run_git(installed, "rev-parse", "HEAD") |
| 97 | (installed / "plugin.py").write_text("value = 'local'\n", encoding="utf-8") |
| 98 | push_source_change(source, "plugin.py", "value = 'upstream'\n") |
| 99 | |
| 100 | with pytest.raises(git_helpers.DirtyTreeConflictError) as exc_info: |
| 101 | git_helpers.update_repo(str(installed)) |
| 102 | |
| 103 | assert exc_info.value.conflicting_files == ["plugin.py"] |
| 104 | assert run_git(installed, "rev-parse", "HEAD") == original_head |
| 105 | assert (installed / "plugin.py").read_text(encoding="utf-8") == "value = 'local'\n" |
| 106 | assert git_status(installed) == " M plugin.py" |
| 107 | assert run_git(installed, "stash", "list") == "" |
| 108 | |
| 109 | |
| 110 | def test_plugin_hub_renders_dirty_update_errors_inline(): |
| 111 | store = (PROJECT_ROOT / "plugins/_plugin_installer/webui/pluginInstallStore.js").read_text(encoding="utf-8") |
| 112 | detail = (PROJECT_ROOT / "plugins/_plugin_installer/webui/install-detail.html").read_text(encoding="utf-8") |
| 113 | |
| 114 | assert "detailError" in store |
| 115 | assert "error_kind" in store |
| 116 | assert "pi-detail-error" in detail |
| 117 | assert "conflicting_files" in detail |
| 118 | |
| 119 | |
| 120 | def test_plugin_update_returns_structured_dirty_tree_error(monkeypatch, tmp_path: Path): |
| 121 | plugin_dir = tmp_path / "plugin" |
| 122 | plugin_dir.mkdir() |
| 123 | monkeypatch.setattr(install.plugins, "find_plugin_dir", lambda _name: str(plugin_dir)) |
| 124 | monkeypatch.setattr(install.files, "get_abs_path", lambda *_parts: str(tmp_path)) |
| 125 | monkeypatch.setattr(install.files, "is_in_dir", lambda *_paths: True) |
| 126 | monkeypatch.setattr(install, "run_pre_update_hook", lambda _name: None) |
| 127 | |
| 128 | def raise_conflict(_path: str): |
| 129 | raise git_helpers.DirtyTreeConflictError(["plugin.py"]) |
| 130 | |
| 131 | monkeypatch.setattr(install.git, "update_repo", raise_conflict) |
| 132 | |
| 133 | assert install.update_from_git("demo") == { |
| 134 | "ok": False, |
| 135 | "success": False, |
| 136 | "error": "Local changes conflict with the update. Your plugin was restored without applying the update.", |
| 137 | "error_kind": "dirty_tree_conflict", |
| 138 | "plugin_name": "demo", |
| 139 | "conflicting_files": ["plugin.py"], |
| 140 | } |