main
py 140 lines 5.67 KB
Raw
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 }