collab-runtime 0.2.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- collab/__init__.py +77 -0
- collab/__main__.py +11 -0
- collab_runtime-0.2.9.dist-info/METADATA +218 -0
- collab_runtime-0.2.9.dist-info/RECORD +82 -0
- collab_runtime-0.2.9.dist-info/WHEEL +5 -0
- collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
- collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
- collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
- scripts/cleanup.py +395 -0
- scripts/collab_git_hook.py +190 -0
- scripts/format_code.py +594 -0
- scripts/generate_tests.py +560 -0
- scripts/validate_code.py +1397 -0
- src/__init__.py +4 -0
- src/dashboard/index.html +1131 -0
- src/live_locks_watcher.py +1982 -0
- src/lock_client.py +4268 -0
- src/logging_config.py +259 -0
- src/main.py +436 -0
- tests/backend/__init__.py +0 -0
- tests/backend/functional/__init__.py +0 -0
- tests/backend/functional/test_package_imports.py +43 -0
- tests/backend/integration/__init__.py +0 -0
- tests/backend/integration/test_cli_contract_parity.py +220 -0
- tests/backend/performance/__init__.py +0 -0
- tests/backend/reliability/__init__.py +0 -0
- tests/backend/security/__init__.py +0 -0
- tests/backend/unit/live_locks_watcher/__init__.py +5 -0
- tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
- tests/backend/unit/live_locks_watcher/conftest.py +18 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
- tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
- tests/backend/unit/lock_client/__init__.py +1 -0
- tests/backend/unit/lock_client/_helpers.py +132 -0
- tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
- tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
- tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
- tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
- tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
- tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
- tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
- tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
- tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
- tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
- tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
- tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
- tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
- tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
- tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
- tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
- tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
- tests/backend/unit/scripts/__init__.py +1 -0
- tests/backend/unit/scripts/_helpers.py +42 -0
- tests/backend/unit/scripts/test_cleanup.py +285 -0
- tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
- tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
- tests/backend/unit/scripts/test_format_code.py +368 -0
- tests/backend/unit/scripts/test_format_code_ported.py +177 -0
- tests/backend/unit/scripts/test_generate_tests.py +305 -0
- tests/backend/unit/scripts/test_hook_templates.py +357 -0
- tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
- tests/backend/unit/scripts/test_validate_code.py +867 -0
- tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
- tests/backend/unit/test_entrypoints_main_run.py +83 -0
- tests/backend/unit/test_logging_config.py +529 -0
- tests/backend/unit/test_main_watch_pid_file.py +278 -0
- tests/conftest.py +167 -0
- tests/frontend/__init__.py +0 -0
- tests/frontend/jest/__init__.py +0 -0
- tests/frontend/playwright/__init__.py +0 -0
- tests/packaging/test_smoke_install.py +76 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unit tests for repository scripts."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Helpers for loading script modules in tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tests.backend.unit.lock_client._helpers import (
|
|
9
|
+
FakeResponse,
|
|
10
|
+
load_lock_client_module,
|
|
11
|
+
make_create_client,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_repo_root() -> Path:
|
|
16
|
+
here = Path(__file__).resolve()
|
|
17
|
+
for parent in here.parents:
|
|
18
|
+
if (parent / "pyproject.toml").exists() and (parent / "scripts").exists():
|
|
19
|
+
return parent
|
|
20
|
+
raise FileNotFoundError("Could not locate repository root")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROOT = _find_repo_root()
|
|
24
|
+
SCRIPTS_DIR = ROOT / "scripts"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_script_module(script_name: str, module_name: str):
|
|
28
|
+
"""Load a script file from scripts/ as an importable module object."""
|
|
29
|
+
module_path = SCRIPTS_DIR / script_name
|
|
30
|
+
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
|
31
|
+
assert spec and spec.loader
|
|
32
|
+
mod = importlib.util.module_from_spec(spec)
|
|
33
|
+
spec.loader.exec_module(mod)
|
|
34
|
+
return mod
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"FakeResponse",
|
|
39
|
+
"load_lock_client_module",
|
|
40
|
+
"load_script_module",
|
|
41
|
+
"make_create_client",
|
|
42
|
+
]
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Tests for scripts/cleanup.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import runpy
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from tests.backend.unit.scripts._helpers import load_script_module
|
|
12
|
+
|
|
13
|
+
cleanup = load_script_module("cleanup.py", "cleanup_under_test")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestIsProtected:
|
|
17
|
+
def test_protected_paths(self):
|
|
18
|
+
assert cleanup._is_protected(Path(".venv/something")) is True
|
|
19
|
+
assert cleanup._is_protected(Path("node_modules/pkg")) is True
|
|
20
|
+
assert cleanup._is_protected(Path(".git/objects")) is True
|
|
21
|
+
assert cleanup._is_protected(Path("instance/db")) is True
|
|
22
|
+
assert cleanup._is_protected(Path("test_data/dummy.json")) is True
|
|
23
|
+
|
|
24
|
+
def test_unprotected_paths(self):
|
|
25
|
+
assert cleanup._is_protected(Path("htmlcov/index.html")) is False
|
|
26
|
+
assert cleanup._is_protected(Path(".coverage")) is False
|
|
27
|
+
assert cleanup._is_protected(Path("__pycache__")) is False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestRemove:
|
|
31
|
+
def test_remove_nonexistent(self, tmp_path):
|
|
32
|
+
ok, msg = cleanup._remove(tmp_path / "ghost", False)
|
|
33
|
+
assert ok is False
|
|
34
|
+
assert msg == ""
|
|
35
|
+
|
|
36
|
+
def test_remove_file(self, tmp_path, monkeypatch):
|
|
37
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
38
|
+
f = tmp_path / ".coverage"
|
|
39
|
+
f.write_text("data", encoding="utf-8")
|
|
40
|
+
ok, _ = cleanup._remove(f, dry_run=False)
|
|
41
|
+
assert ok is True
|
|
42
|
+
assert not f.exists()
|
|
43
|
+
|
|
44
|
+
def test_remove_directory(self, tmp_path, monkeypatch):
|
|
45
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
46
|
+
d = tmp_path / "htmlcov"
|
|
47
|
+
d.mkdir()
|
|
48
|
+
(d / "index.html").write_text("<html/>", encoding="utf-8")
|
|
49
|
+
ok, _ = cleanup._remove(d, dry_run=False)
|
|
50
|
+
assert ok is True
|
|
51
|
+
assert not d.exists()
|
|
52
|
+
|
|
53
|
+
def test_dry_run_does_not_delete(self, tmp_path, monkeypatch):
|
|
54
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
55
|
+
f = tmp_path / "coverage.xml"
|
|
56
|
+
f.write_text("xml", encoding="utf-8")
|
|
57
|
+
ok, msg = cleanup._remove(f, dry_run=True)
|
|
58
|
+
assert ok is True
|
|
59
|
+
assert "[DRY-RUN]" in msg
|
|
60
|
+
assert f.exists()
|
|
61
|
+
|
|
62
|
+
def test_remove_permission_error(self, tmp_path, monkeypatch):
|
|
63
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
64
|
+
f = tmp_path / "locked"
|
|
65
|
+
f.write_text("x", encoding="utf-8")
|
|
66
|
+
monkeypatch.setattr(Path, "is_dir", lambda self: False)
|
|
67
|
+
monkeypatch.setattr(
|
|
68
|
+
Path,
|
|
69
|
+
"unlink",
|
|
70
|
+
lambda self: (_ for _ in ()).throw(PermissionError("nope")),
|
|
71
|
+
)
|
|
72
|
+
ok, msg = cleanup._remove(f, dry_run=False)
|
|
73
|
+
assert ok is False
|
|
74
|
+
assert "Could not remove" in msg
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestCleanItemsAndGlob:
|
|
78
|
+
def test_cleans_existing_items(self, tmp_path, monkeypatch):
|
|
79
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
80
|
+
(tmp_path / "htmlcov").mkdir()
|
|
81
|
+
(tmp_path / ".coverage").write_text("data", encoding="utf-8")
|
|
82
|
+
count = cleanup._clean_items(["htmlcov", ".coverage", "nonexistent"], False)
|
|
83
|
+
assert count == 2
|
|
84
|
+
|
|
85
|
+
def test_cleans_matching_patterns(self, tmp_path, monkeypatch):
|
|
86
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
87
|
+
cache = tmp_path / "__pycache__"
|
|
88
|
+
cache.mkdir()
|
|
89
|
+
(cache / "mod.pyc").write_text("", encoding="utf-8")
|
|
90
|
+
count = cleanup._clean_glob(["**/__pycache__"], False)
|
|
91
|
+
assert count >= 1
|
|
92
|
+
|
|
93
|
+
def test_skips_protected(self, tmp_path, monkeypatch):
|
|
94
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
95
|
+
protected = tmp_path / "node_modules"
|
|
96
|
+
protected.mkdir()
|
|
97
|
+
count = cleanup._clean_glob(["node_modules"], False)
|
|
98
|
+
assert count == 0
|
|
99
|
+
assert protected.exists()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestCleanFunctions:
|
|
103
|
+
def test_clean_coverage(self, tmp_path, monkeypatch, capsys):
|
|
104
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
105
|
+
(tmp_path / "coverage").mkdir()
|
|
106
|
+
(tmp_path / "coverage" / "lcov.info").write_text("lcov", encoding="utf-8")
|
|
107
|
+
(tmp_path / ".coverage").write_text("data", encoding="utf-8")
|
|
108
|
+
(tmp_path / "coverage.xml").write_text("<xml/>", encoding="utf-8")
|
|
109
|
+
count = cleanup.clean_coverage(dry_run=False)
|
|
110
|
+
assert count >= 3
|
|
111
|
+
assert "Cleaning coverage" in capsys.readouterr().out
|
|
112
|
+
|
|
113
|
+
def test_clean_test_output(self, tmp_path, monkeypatch, capsys):
|
|
114
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
115
|
+
(tmp_path / "test-results").mkdir()
|
|
116
|
+
(tmp_path / "playwright-report").mkdir()
|
|
117
|
+
(tmp_path / "blob-report").mkdir()
|
|
118
|
+
count = cleanup.clean_test_output(dry_run=False)
|
|
119
|
+
assert count >= 3
|
|
120
|
+
assert "Cleaning test output" in capsys.readouterr().out
|
|
121
|
+
|
|
122
|
+
def test_clean_caches(self, tmp_path, monkeypatch, capsys):
|
|
123
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
124
|
+
(tmp_path / ".pytest_cache").mkdir()
|
|
125
|
+
(tmp_path / ".ruff_cache").mkdir()
|
|
126
|
+
count = cleanup.clean_caches(dry_run=False)
|
|
127
|
+
assert count >= 2
|
|
128
|
+
assert "Cleaning tool caches" in capsys.readouterr().out
|
|
129
|
+
|
|
130
|
+
def test_clean_default_and_all(self, tmp_path, monkeypatch):
|
|
131
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
132
|
+
(tmp_path / ".coverage").write_text("data", encoding="utf-8")
|
|
133
|
+
(tmp_path / "test-results").mkdir()
|
|
134
|
+
(tmp_path / ".ruff_cache").mkdir()
|
|
135
|
+
assert cleanup.clean_default(dry_run=False) >= 2
|
|
136
|
+
assert cleanup.clean_all(dry_run=False) >= 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestMainCLI:
|
|
140
|
+
def _run_main(self, monkeypatch, tmp_path, args):
|
|
141
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
142
|
+
monkeypatch.setattr(sys, "argv", ["cleanup.py"] + args)
|
|
143
|
+
return cleanup.main()
|
|
144
|
+
|
|
145
|
+
def test_default_mode(self, monkeypatch, tmp_path, capsys):
|
|
146
|
+
(tmp_path / ".coverage").write_text("x", encoding="utf-8")
|
|
147
|
+
rc = self._run_main(monkeypatch, tmp_path, [])
|
|
148
|
+
assert rc == 0
|
|
149
|
+
assert "test artifacts + coverage" in capsys.readouterr().out
|
|
150
|
+
|
|
151
|
+
def test_switches(self, monkeypatch, tmp_path, capsys):
|
|
152
|
+
(tmp_path / ".ruff_cache").mkdir()
|
|
153
|
+
assert self._run_main(monkeypatch, tmp_path, ["--all"]) == 0
|
|
154
|
+
|
|
155
|
+
(tmp_path / "coverage.xml").write_text("x", encoding="utf-8")
|
|
156
|
+
assert self._run_main(monkeypatch, tmp_path, ["--coverage"]) == 0
|
|
157
|
+
|
|
158
|
+
(tmp_path / "test-results").mkdir()
|
|
159
|
+
assert self._run_main(monkeypatch, tmp_path, ["--tests"]) == 0
|
|
160
|
+
|
|
161
|
+
(tmp_path / ".mypy_cache").mkdir()
|
|
162
|
+
assert self._run_main(monkeypatch, tmp_path, ["--caches"]) == 0
|
|
163
|
+
|
|
164
|
+
out = capsys.readouterr().out
|
|
165
|
+
assert "ARTIFACT CLEANUP" in out
|
|
166
|
+
|
|
167
|
+
def test_main_nothing_to_clean_and_dry_run_banner(
|
|
168
|
+
self,
|
|
169
|
+
monkeypatch,
|
|
170
|
+
tmp_path,
|
|
171
|
+
capsys,
|
|
172
|
+
):
|
|
173
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
174
|
+
rc = self._run_main(monkeypatch, tmp_path, ["--dry-run"])
|
|
175
|
+
assert rc == 0
|
|
176
|
+
out = capsys.readouterr().out
|
|
177
|
+
assert "[DRY-RUN]" in out
|
|
178
|
+
assert "Nothing to clean" in out
|
|
179
|
+
|
|
180
|
+
def test_packaging_dry_run(self, monkeypatch, tmp_path, capsys):
|
|
181
|
+
# Dry-run should list packaging artifacts without deleting
|
|
182
|
+
(tmp_path / "dist").mkdir()
|
|
183
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
184
|
+
monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging", "--dry-run"])
|
|
185
|
+
rc = cleanup.main()
|
|
186
|
+
assert rc == 0
|
|
187
|
+
out = capsys.readouterr().out
|
|
188
|
+
assert "[DRY-RUN]" in out
|
|
189
|
+
assert "Cleaning packaging artifacts" in out
|
|
190
|
+
|
|
191
|
+
def test_packaging_abort_on_no(self, monkeypatch, tmp_path, capsys):
|
|
192
|
+
# If user answers 'no' to confirmation, main should abort with code 2
|
|
193
|
+
(tmp_path / "dist").mkdir()
|
|
194
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
195
|
+
monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging"])
|
|
196
|
+
monkeypatch.setattr("builtins.input", lambda prompt: "n")
|
|
197
|
+
rc = cleanup.main()
|
|
198
|
+
assert rc == 2
|
|
199
|
+
out = capsys.readouterr().out
|
|
200
|
+
assert "Aborted by user." in out
|
|
201
|
+
|
|
202
|
+
def test_packaging_confirm_yes_removes(self, monkeypatch, tmp_path, capsys):
|
|
203
|
+
# If user confirms, packaging artifacts should be removed
|
|
204
|
+
d = tmp_path / "dist"
|
|
205
|
+
d.mkdir()
|
|
206
|
+
(tmp_path / "build").mkdir()
|
|
207
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
208
|
+
monkeypatch.setattr(sys, "argv", ["cleanup.py", "--packaging"])
|
|
209
|
+
monkeypatch.setattr("builtins.input", lambda prompt: "y")
|
|
210
|
+
rc = cleanup.main()
|
|
211
|
+
assert rc == 0
|
|
212
|
+
out = capsys.readouterr().out
|
|
213
|
+
assert "Removed" in out or "Would remove" in out
|
|
214
|
+
assert not d.exists()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_clean_glob_duplicate_and_value_error_paths(monkeypatch, tmp_path):
|
|
218
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
219
|
+
d = tmp_path / "__pycache__"
|
|
220
|
+
d.mkdir()
|
|
221
|
+
|
|
222
|
+
# Duplicate pattern should hit seen-guard path.
|
|
223
|
+
assert cleanup._clean_glob(["__pycache__", "__pycache__"], False) == 1
|
|
224
|
+
|
|
225
|
+
class _BadPath:
|
|
226
|
+
def __hash__(self):
|
|
227
|
+
return 1
|
|
228
|
+
|
|
229
|
+
def __eq__(self, _other):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
def relative_to(self, _root):
|
|
233
|
+
raise ValueError("outside")
|
|
234
|
+
|
|
235
|
+
class _FakeRoot:
|
|
236
|
+
def glob(self, _pattern):
|
|
237
|
+
return [_BadPath()]
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(cleanup, "ROOT", _FakeRoot())
|
|
240
|
+
assert cleanup._clean_glob(["anything"], False) == 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_remove_handles_oserror(tmp_path, monkeypatch):
|
|
244
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
245
|
+
target = tmp_path / "problem.txt"
|
|
246
|
+
target.write_text("x", encoding="utf-8")
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
Path,
|
|
250
|
+
"unlink",
|
|
251
|
+
lambda self: (_ for _ in ()).throw(OSError("busy")),
|
|
252
|
+
)
|
|
253
|
+
ok, msg = cleanup._remove(target, dry_run=False)
|
|
254
|
+
assert ok is False
|
|
255
|
+
assert "Could not remove" in msg
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_clean_glob_skips_duplicate_entries(monkeypatch, tmp_path):
|
|
259
|
+
duplicate = tmp_path / "dup.pyc"
|
|
260
|
+
duplicate.write_text("data", encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(cleanup, "ROOT", tmp_path)
|
|
263
|
+
monkeypatch.setattr(
|
|
264
|
+
cleanup.Path,
|
|
265
|
+
"glob",
|
|
266
|
+
lambda self, _pattern: [duplicate, duplicate] if self == tmp_path else [],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
removed = []
|
|
270
|
+
monkeypatch.setattr(
|
|
271
|
+
cleanup,
|
|
272
|
+
"_remove",
|
|
273
|
+
lambda path, dry_run: removed.append(path) or (True, ""),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
count = cleanup._clean_glob(["**/*.pyc"], dry_run=False)
|
|
277
|
+
assert count == 1
|
|
278
|
+
assert removed == [duplicate]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_cleanup_module_dunder_main(monkeypatch):
|
|
282
|
+
monkeypatch.setattr(sys, "argv", ["cleanup.py", "--dry-run"])
|
|
283
|
+
with pytest.raises(SystemExit) as exc:
|
|
284
|
+
runpy.run_path("scripts/cleanup.py", run_name="__main__")
|
|
285
|
+
assert exc.value.code == 0
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Tests for scripts/collab_git_hook.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import runpy
|
|
9
|
+
import sys
|
|
10
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from tests.backend.unit.scripts._helpers import load_script_module
|
|
17
|
+
|
|
18
|
+
hook = load_script_module("collab_git_hook.py", "collab_git_hook_under_test")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_git_output_success(monkeypatch):
|
|
22
|
+
monkeypatch.setattr(
|
|
23
|
+
hook.subprocess,
|
|
24
|
+
"run",
|
|
25
|
+
lambda *a, **k: SimpleNamespace(returncode=0, stdout="a\n", stderr=""),
|
|
26
|
+
)
|
|
27
|
+
assert hook._git_output("status") == "a"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_git_output_error_raises(monkeypatch):
|
|
31
|
+
monkeypatch.setattr(
|
|
32
|
+
hook.subprocess,
|
|
33
|
+
"run",
|
|
34
|
+
lambda *a, **k: SimpleNamespace(returncode=1, stdout="", stderr="boom"),
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
hook._git_output("status")
|
|
38
|
+
assert False
|
|
39
|
+
except RuntimeError as exc:
|
|
40
|
+
assert "boom" in str(exc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_get_staged_files(monkeypatch):
|
|
44
|
+
monkeypatch.setattr(hook, "_git_output", lambda *a: "a.py\n\n b.py \n")
|
|
45
|
+
assert hook._get_staged_files() == ["a.py", "b.py"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_read_pid_file_missing(monkeypatch, tmp_path):
|
|
49
|
+
pid_file = tmp_path / "daemon.pid"
|
|
50
|
+
monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
|
|
51
|
+
assert hook._read_pid_file() is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_read_pid_file_json_and_plain(monkeypatch, tmp_path):
|
|
55
|
+
pid_file = tmp_path / "daemon.pid"
|
|
56
|
+
monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
|
|
57
|
+
|
|
58
|
+
pid_file.write_text(json.dumps({"pid": 123}), encoding="utf-8")
|
|
59
|
+
assert hook._read_pid_file() == 123
|
|
60
|
+
|
|
61
|
+
pid_file.write_text("456", encoding="utf-8")
|
|
62
|
+
assert hook._read_pid_file() == 456
|
|
63
|
+
|
|
64
|
+
pid_file.write_text("not-int", encoding="utf-8")
|
|
65
|
+
assert hook._read_pid_file() is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_read_pid_file_empty_and_oserror(monkeypatch, tmp_path):
|
|
69
|
+
pid_file = tmp_path / "daemon.pid"
|
|
70
|
+
monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
|
|
71
|
+
|
|
72
|
+
pid_file.write_text("\n", encoding="utf-8")
|
|
73
|
+
assert hook._read_pid_file() is None
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(
|
|
76
|
+
Path,
|
|
77
|
+
"read_text",
|
|
78
|
+
lambda *a, **k: (_ for _ in ()).throw(OSError("x")),
|
|
79
|
+
)
|
|
80
|
+
assert hook._read_pid_file() is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_pid_is_running_psutil_branch(monkeypatch):
|
|
84
|
+
class _Psutil:
|
|
85
|
+
@staticmethod
|
|
86
|
+
def pid_exists(_pid):
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
monkeypatch.setitem(sys.modules, "psutil", _Psutil)
|
|
90
|
+
assert hook._pid_is_running(1) is True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_pid_is_running_fallback_kill(monkeypatch):
|
|
94
|
+
monkeypatch.setitem(
|
|
95
|
+
sys.modules,
|
|
96
|
+
"psutil",
|
|
97
|
+
SimpleNamespace(pid_exists=lambda _pid: (_ for _ in ()).throw(RuntimeError())),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
called = {"count": 0}
|
|
101
|
+
|
|
102
|
+
def _fake_kill(pid, sig):
|
|
103
|
+
called["count"] += 1
|
|
104
|
+
if pid == 10:
|
|
105
|
+
raise OSError("gone")
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(os, "kill", _fake_kill)
|
|
108
|
+
assert hook._pid_is_running(9) is True
|
|
109
|
+
assert hook._pid_is_running(10) is False
|
|
110
|
+
assert called["count"] >= 2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_pid_is_running_permission_error(monkeypatch):
|
|
114
|
+
monkeypatch.setitem(
|
|
115
|
+
sys.modules,
|
|
116
|
+
"psutil",
|
|
117
|
+
SimpleNamespace(pid_exists=lambda _pid: (_ for _ in ()).throw(RuntimeError())),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _fake_kill(_pid, _sig):
|
|
121
|
+
raise PermissionError("denied")
|
|
122
|
+
|
|
123
|
+
monkeypatch.setattr(os, "kill", _fake_kill)
|
|
124
|
+
assert hook._pid_is_running(123) is True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_watcher_pid(monkeypatch):
|
|
128
|
+
monkeypatch.setattr(hook, "_read_pid_file", lambda: 77)
|
|
129
|
+
monkeypatch.setattr(hook, "_pid_is_running", lambda pid: True)
|
|
130
|
+
assert hook._watcher_pid() == 77
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(hook, "_pid_is_running", lambda pid: False)
|
|
133
|
+
assert hook._watcher_pid() is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_watcher_pid_none_branch(monkeypatch):
|
|
137
|
+
monkeypatch.setattr(hook, "_read_pid_file", lambda: None)
|
|
138
|
+
assert hook._watcher_pid() is None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_acquire_staged_no_files(monkeypatch):
|
|
142
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: [])
|
|
143
|
+
assert hook.acquire_staged() == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_acquire_staged_skips_when_watcher_running(monkeypatch):
|
|
147
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
|
|
148
|
+
monkeypatch.setattr(hook, "_watcher_pid", lambda: 999)
|
|
149
|
+
err = io.StringIO()
|
|
150
|
+
with redirect_stderr(err):
|
|
151
|
+
rc = hook.acquire_staged()
|
|
152
|
+
assert rc == 0
|
|
153
|
+
assert "Watcher running" in err.getvalue()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_acquire_staged_strict_failure(monkeypatch):
|
|
157
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
|
|
158
|
+
monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
|
|
159
|
+
|
|
160
|
+
class _BrokenClient:
|
|
161
|
+
def __init__(self):
|
|
162
|
+
raise RuntimeError("lock backend down")
|
|
163
|
+
|
|
164
|
+
monkeypatch.setattr("src.lock_client.LockClient", _BrokenClient)
|
|
165
|
+
monkeypatch.setenv("LOCK_STRICT", "1")
|
|
166
|
+
err = io.StringIO()
|
|
167
|
+
with redirect_stderr(err):
|
|
168
|
+
rc = hook.acquire_staged()
|
|
169
|
+
assert rc == 1
|
|
170
|
+
assert "lock check failed" in err.getvalue()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_acquire_staged_conflict(monkeypatch):
|
|
174
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
|
|
175
|
+
monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
|
|
176
|
+
|
|
177
|
+
class _Client:
|
|
178
|
+
def acquire_multiple(self, *_a, **_k):
|
|
179
|
+
return False, ["a.py"], "conflict"
|
|
180
|
+
|
|
181
|
+
def get_lock_status(self, _f):
|
|
182
|
+
return {"locked_by": "dev1"}
|
|
183
|
+
|
|
184
|
+
monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
|
|
185
|
+
|
|
186
|
+
err = io.StringIO()
|
|
187
|
+
with redirect_stderr(err):
|
|
188
|
+
rc = hook.acquire_staged()
|
|
189
|
+
assert rc == 1
|
|
190
|
+
assert "Commit blocked" in err.getvalue()
|
|
191
|
+
assert "@dev1" in err.getvalue()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_acquire_staged_conflict_status_exception(monkeypatch):
|
|
195
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py"])
|
|
196
|
+
monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
|
|
197
|
+
|
|
198
|
+
class _Client:
|
|
199
|
+
def acquire_multiple(self, *_a, **_k):
|
|
200
|
+
return False, ["a.py"], "conflict"
|
|
201
|
+
|
|
202
|
+
def get_lock_status(self, _f):
|
|
203
|
+
raise RuntimeError("boom")
|
|
204
|
+
|
|
205
|
+
monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
|
|
206
|
+
|
|
207
|
+
err = io.StringIO()
|
|
208
|
+
with redirect_stderr(err):
|
|
209
|
+
rc = hook.acquire_staged()
|
|
210
|
+
assert rc == 1
|
|
211
|
+
assert "@unknown" in err.getvalue()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_acquire_staged_success(monkeypatch):
|
|
215
|
+
monkeypatch.setattr(hook, "_get_staged_files", lambda: ["a.py", "b.py"])
|
|
216
|
+
monkeypatch.setattr(hook, "_watcher_pid", lambda: None)
|
|
217
|
+
|
|
218
|
+
class _Client:
|
|
219
|
+
def acquire_multiple(self, *_a, **_k):
|
|
220
|
+
return True, [], "ok"
|
|
221
|
+
|
|
222
|
+
monkeypatch.setattr("src.lock_client.LockClient", lambda: _Client())
|
|
223
|
+
err = io.StringIO()
|
|
224
|
+
with redirect_stderr(err):
|
|
225
|
+
rc = hook.acquire_staged()
|
|
226
|
+
assert rc == 0
|
|
227
|
+
assert "Locks acquired" in err.getvalue()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_release_all_success_and_failure(monkeypatch):
|
|
231
|
+
class _ClientOk:
|
|
232
|
+
def release_all(self):
|
|
233
|
+
return 3
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr("src.lock_client.LockClient", lambda: _ClientOk())
|
|
236
|
+
err = io.StringIO()
|
|
237
|
+
with redirect_stderr(err):
|
|
238
|
+
assert hook.release_all() == 0
|
|
239
|
+
assert "Released 3" in err.getvalue()
|
|
240
|
+
|
|
241
|
+
class _ClientBad:
|
|
242
|
+
def __init__(self):
|
|
243
|
+
raise RuntimeError("fail")
|
|
244
|
+
|
|
245
|
+
monkeypatch.setattr("src.lock_client.LockClient", _ClientBad)
|
|
246
|
+
err = io.StringIO()
|
|
247
|
+
with redirect_stderr(err):
|
|
248
|
+
assert hook.release_all() == 0
|
|
249
|
+
assert "lock cleanup failed" in err.getvalue()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_main_command_dispatch(monkeypatch):
|
|
253
|
+
monkeypatch.setattr(hook, "acquire_staged", lambda: 7)
|
|
254
|
+
monkeypatch.setattr(hook, "release_all", lambda: 8)
|
|
255
|
+
|
|
256
|
+
monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "acquire-staged"])
|
|
257
|
+
assert hook.main() == 7
|
|
258
|
+
|
|
259
|
+
monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "release-all"])
|
|
260
|
+
assert hook.main() == 8
|
|
261
|
+
|
|
262
|
+
monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "unknown"])
|
|
263
|
+
err = io.StringIO()
|
|
264
|
+
with redirect_stderr(err):
|
|
265
|
+
assert hook.main() == 2
|
|
266
|
+
assert "Unknown command" in err.getvalue()
|
|
267
|
+
|
|
268
|
+
monkeypatch.setattr(sys, "argv", ["collab_git_hook.py"])
|
|
269
|
+
out = io.StringIO()
|
|
270
|
+
with redirect_stdout(out):
|
|
271
|
+
assert hook.main() == 2
|
|
272
|
+
assert "Usage" in out.getvalue()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_collab_git_hook_dunder_main(monkeypatch):
|
|
276
|
+
monkeypatch.setattr(sys, "argv", ["collab_git_hook.py", "acquire-staged"])
|
|
277
|
+
monkeypatch.setattr(hook, "acquire_staged", lambda: 0)
|
|
278
|
+
with pytest.raises(SystemExit) as exc:
|
|
279
|
+
runpy.run_path("scripts/collab_git_hook.py", run_name="__main__")
|
|
280
|
+
assert exc.value.code == 0
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Extra branch coverage tests for scripts/collab_git_hook.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _repo_root() -> Path:
|
|
12
|
+
here = Path(__file__).resolve()
|
|
13
|
+
for parent in here.parents:
|
|
14
|
+
if (parent / "pyproject.toml").exists() and (parent / "scripts").exists():
|
|
15
|
+
return parent
|
|
16
|
+
raise FileNotFoundError("Could not locate repository root")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_fresh(module_name: str):
|
|
20
|
+
root = _repo_root()
|
|
21
|
+
module_path = root / "scripts" / "collab_git_hook.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
|
23
|
+
assert spec and spec.loader
|
|
24
|
+
mod = importlib.util.module_from_spec(spec)
|
|
25
|
+
spec.loader.exec_module(mod)
|
|
26
|
+
return mod
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_src_root_added_to_sys_path(monkeypatch):
|
|
30
|
+
root = _repo_root()
|
|
31
|
+
src_root = str((root / "src").resolve())
|
|
32
|
+
if src_root in sys.path:
|
|
33
|
+
sys.path.remove(src_root)
|
|
34
|
+
|
|
35
|
+
mod = _load_fresh("collab_git_hook_fresh_path")
|
|
36
|
+
assert str(mod.SRC_ROOT) in sys.path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_read_pid_file_json_decode_and_non_int_pid(monkeypatch, tmp_path):
|
|
40
|
+
from tests.backend.unit.scripts._helpers import load_script_module
|
|
41
|
+
|
|
42
|
+
hook = load_script_module("collab_git_hook.py", "collab_git_hook_extra_pid")
|
|
43
|
+
pid_file = tmp_path / "daemon.pid"
|
|
44
|
+
monkeypatch.setattr("src.lock_client.PID_FILE", str(pid_file))
|
|
45
|
+
|
|
46
|
+
pid_file.write_text("{bad-json", encoding="utf-8")
|
|
47
|
+
assert hook._read_pid_file() is None
|
|
48
|
+
|
|
49
|
+
pid_file.write_text(json.dumps({"pid": "abc"}), encoding="utf-8")
|
|
50
|
+
assert hook._read_pid_file() is None
|