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,305 @@
|
|
|
1
|
+
"""Tests for scripts/generate_tests.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import runpy
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tests.backend.unit.scripts._helpers import load_script_module
|
|
11
|
+
|
|
12
|
+
gen = load_script_module("generate_tests.py", "generate_tests_under_test")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestCodeAnalyzer:
|
|
16
|
+
def test_analyze_finds_public_functions_and_classes(self, tmp_path):
|
|
17
|
+
src = tmp_path / "sample.py"
|
|
18
|
+
src.write_text(
|
|
19
|
+
"class Foo:\n pass\n\n"
|
|
20
|
+
"def bar():\n pass\n\n"
|
|
21
|
+
"def _private():\n pass\n",
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
analyzer = gen.CodeAnalyzer(str(src))
|
|
25
|
+
entities = analyzer.analyze()
|
|
26
|
+
names = [e[0] for e in entities]
|
|
27
|
+
assert "Foo" in names
|
|
28
|
+
assert "bar" in names
|
|
29
|
+
assert "_private" not in names
|
|
30
|
+
|
|
31
|
+
def test_analyze_async_and_bom_and_syntax_error(self, tmp_path, capsys):
|
|
32
|
+
src = tmp_path / "async_mod.py"
|
|
33
|
+
src.write_text("async def fetch():\n return 1\n", encoding="utf-8")
|
|
34
|
+
assert gen.CodeAnalyzer(str(src)).analyze() == [("fetch", "function")]
|
|
35
|
+
|
|
36
|
+
bom = tmp_path / "bom_mod.py"
|
|
37
|
+
bom.write_text("\ufeffdef hello():\n return 'hi'\n", encoding="utf-8")
|
|
38
|
+
assert gen.CodeAnalyzer(str(bom)).analyze() == [("hello", "function")]
|
|
39
|
+
|
|
40
|
+
bad = tmp_path / "bad.py"
|
|
41
|
+
bad.write_text("def broken(:\n pass\n", encoding="utf-8")
|
|
42
|
+
assert gen.CodeAnalyzer(str(bad)).analyze() == []
|
|
43
|
+
assert "Syntax error" in capsys.readouterr().out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestTestGenerator:
|
|
47
|
+
def test_detect_category(self):
|
|
48
|
+
assert gen.TestGenerator("src/routes/api.py").category == "functional"
|
|
49
|
+
assert gen.TestGenerator("src/lock_client.py").category == "unit"
|
|
50
|
+
assert gen.TestGenerator("src/unknown.py").category == "unit"
|
|
51
|
+
|
|
52
|
+
def test_generate_empty_entities(self):
|
|
53
|
+
tg = gen.TestGenerator("src/foo.py")
|
|
54
|
+
assert tg.generate([]) == ""
|
|
55
|
+
|
|
56
|
+
def test_generate_import_block_for_src(self):
|
|
57
|
+
tg = gen.TestGenerator("src/my_module.py")
|
|
58
|
+
code = tg.generate([("MyClass", "class"), ("do_it", "function")])
|
|
59
|
+
assert "import pytest" in code
|
|
60
|
+
assert "from src.my_module import (" in code
|
|
61
|
+
assert "class TestMyClass:" in code
|
|
62
|
+
assert "test_do_it_is_callable" in code
|
|
63
|
+
|
|
64
|
+
def test_generate_path_loader_block_for_scripts(self):
|
|
65
|
+
tg = gen.TestGenerator("scripts/cleanup.py")
|
|
66
|
+
code = tg.generate([("clean_default", "function")])
|
|
67
|
+
assert "import importlib.util" in code
|
|
68
|
+
assert "def _find_repo_root() -> Path:" in code
|
|
69
|
+
assert "module_under_test = _load_module()" in code
|
|
70
|
+
assert "clean_default = module_under_test.clean_default" in code
|
|
71
|
+
|
|
72
|
+
def test_get_import_path(self, tmp_path):
|
|
73
|
+
tg = gen.TestGenerator("src/foo.py")
|
|
74
|
+
assert tg._get_import_path() == "foo"
|
|
75
|
+
|
|
76
|
+
tg2 = gen.TestGenerator("other/bar.py")
|
|
77
|
+
assert tg2._get_import_path().endswith("bar")
|
|
78
|
+
|
|
79
|
+
external_src = tmp_path / "src" / "pkg" / "mod.py"
|
|
80
|
+
external_src.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
external_src.write_text("x = 1\n", encoding="utf-8")
|
|
82
|
+
tg3 = gen.TestGenerator(
|
|
83
|
+
str(external_src), repo_root=tmp_path / "different-root"
|
|
84
|
+
)
|
|
85
|
+
tg3.relative_source_path = None
|
|
86
|
+
assert tg3._get_import_path() == "pkg.mod"
|
|
87
|
+
|
|
88
|
+
external_no_src = tmp_path / "outside" / "simple.py"
|
|
89
|
+
external_no_src.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
external_no_src.write_text("x = 1\n", encoding="utf-8")
|
|
91
|
+
tg4 = gen.TestGenerator(
|
|
92
|
+
str(external_no_src), repo_root=tmp_path / "another-root"
|
|
93
|
+
)
|
|
94
|
+
tg4.relative_source_path = None
|
|
95
|
+
assert tg4._get_import_path() == "simple"
|
|
96
|
+
|
|
97
|
+
def test_direct_import_module_extra_branches(self, tmp_path):
|
|
98
|
+
tg = gen.TestGenerator("run.py")
|
|
99
|
+
assert tg._get_direct_import_module() == "run"
|
|
100
|
+
|
|
101
|
+
# Force external-path branch with src/ in the absolute path.
|
|
102
|
+
external = tmp_path / "src" / "nested" / "mod.py"
|
|
103
|
+
external.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
external.write_text("def x():\n return 1\n", encoding="utf-8")
|
|
105
|
+
tg2 = gen.TestGenerator(str(external), repo_root=tmp_path / "other-root")
|
|
106
|
+
assert tg2._get_direct_import_module() == "src.nested.mod"
|
|
107
|
+
|
|
108
|
+
def test_generate_adds_blank_line_when_import_block_has_no_trailing_empty(self):
|
|
109
|
+
tg = gen.TestGenerator("src/my_mod.py")
|
|
110
|
+
tg._build_import_block = ( # type: ignore[method-assign]
|
|
111
|
+
lambda _names: ["import pytest"]
|
|
112
|
+
)
|
|
113
|
+
code = tg.generate([("Foo", "class")])
|
|
114
|
+
assert "\n\nclass TestFoo:" in code
|
|
115
|
+
|
|
116
|
+
def test_build_module_path_expression_for_external_source(self, tmp_path):
|
|
117
|
+
ext = tmp_path / "external.py"
|
|
118
|
+
ext.write_text("x=1\n", encoding="utf-8")
|
|
119
|
+
tg = gen.TestGenerator(str(ext), repo_root=tmp_path / "repo2")
|
|
120
|
+
expr = tg._build_module_path_expression()
|
|
121
|
+
assert "Path(" in expr
|
|
122
|
+
|
|
123
|
+
def test_get_test_dir_and_file(self, tmp_path):
|
|
124
|
+
src = tmp_path / "src" / "collab"
|
|
125
|
+
src.mkdir(parents=True)
|
|
126
|
+
source_file = src / "foo.py"
|
|
127
|
+
source_file.write_text("def x():\n return 1\n", encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
tg = gen.TestGenerator(str(source_file), repo_root=tmp_path)
|
|
130
|
+
assert tg.get_test_dir() == tmp_path / "tests" / "backend" / "unit" / "collab"
|
|
131
|
+
assert tg.get_test_file() == (
|
|
132
|
+
tmp_path / "tests" / "backend" / "unit" / "collab" / "test_foo.py"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
custom_root = tmp_path / "out"
|
|
136
|
+
assert tg.get_test_dir(output_root=custom_root) == custom_root / "unit"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestDiscovery:
|
|
140
|
+
def test_find_untested_repo_scan(self, tmp_path):
|
|
141
|
+
(tmp_path / "pyproject.toml").write_text(
|
|
142
|
+
"[tool.pytest.ini_options]\n", encoding="utf-8"
|
|
143
|
+
)
|
|
144
|
+
(tmp_path / "AGENTS.md").write_text("# repo\n", encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
scripts_dir = tmp_path / "scripts"
|
|
147
|
+
scripts_dir.mkdir()
|
|
148
|
+
(scripts_dir / "cleanup.py").write_text(
|
|
149
|
+
"def clean_default():\n return None\n", encoding="utf-8"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
src_dir = tmp_path / "src"
|
|
153
|
+
src_dir.mkdir(parents=True)
|
|
154
|
+
(src_dir / "alpha.py").write_text("def x():\n return 1\n", encoding="utf-8")
|
|
155
|
+
|
|
156
|
+
tests_dir = tmp_path / "tests" / "backend" / "unit"
|
|
157
|
+
tests_dir.mkdir(parents=True)
|
|
158
|
+
(tests_dir / "test_cleanup.py").write_text("# existing\n", encoding="utf-8")
|
|
159
|
+
|
|
160
|
+
disc = gen.TestDiscovery(repo_root=tmp_path)
|
|
161
|
+
untested = disc.find_untested()
|
|
162
|
+
|
|
163
|
+
assert "scripts/cleanup.py" not in untested
|
|
164
|
+
assert "src/alpha.py" in untested
|
|
165
|
+
|
|
166
|
+
def test_find_untested_external(self, tmp_path):
|
|
167
|
+
src_dir = tmp_path / "external"
|
|
168
|
+
src_dir.mkdir()
|
|
169
|
+
(src_dir / "beta.py").write_text("x=1\n", encoding="utf-8")
|
|
170
|
+
|
|
171
|
+
disc = gen.TestDiscovery(repo_root=gen.ROOT)
|
|
172
|
+
untested = disc.find_untested(str(src_dir))
|
|
173
|
+
assert any("beta.py" in p for p in untested)
|
|
174
|
+
|
|
175
|
+
def test_find_untested_returns_empty_for_missing_path(self, tmp_path):
|
|
176
|
+
disc = gen.TestDiscovery(repo_root=tmp_path)
|
|
177
|
+
assert disc.find_untested(str(tmp_path / "does-not-exist")) == []
|
|
178
|
+
|
|
179
|
+
def test_iter_repo_source_files_for_non_repo_path(self, tmp_path):
|
|
180
|
+
src = tmp_path / "src"
|
|
181
|
+
src.mkdir()
|
|
182
|
+
f = src / "a.py"
|
|
183
|
+
f.write_text("x=1\n", encoding="utf-8")
|
|
184
|
+
disc = gen.TestDiscovery(repo_root=tmp_path / "other")
|
|
185
|
+
files = list(disc._iter_repo_source_files(src))
|
|
186
|
+
assert f.resolve() in files
|
|
187
|
+
|
|
188
|
+
def test_iter_python_files_file_mode_and_hidden_skip(self, tmp_path):
|
|
189
|
+
file_path = tmp_path / "alpha.py"
|
|
190
|
+
file_path.write_text("x=1\n", encoding="utf-8")
|
|
191
|
+
disc = gen.TestDiscovery(repo_root=tmp_path)
|
|
192
|
+
files = list(disc._iter_python_files(file_path))
|
|
193
|
+
assert files == [file_path.resolve()]
|
|
194
|
+
assert disc._should_skip_dir(tmp_path / ".hidden") is True
|
|
195
|
+
assert disc._is_candidate_source(tmp_path / "tests" / "x.py") is False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class TestMain:
|
|
199
|
+
def test_scan_mode(self, monkeypatch, capsys):
|
|
200
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", "--scan"])
|
|
201
|
+
gen.main()
|
|
202
|
+
out = capsys.readouterr().out
|
|
203
|
+
assert "Untested modules" in out or "All modules have tests" in out
|
|
204
|
+
|
|
205
|
+
def test_no_source_file_prints_help(self, monkeypatch):
|
|
206
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py"])
|
|
207
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
208
|
+
gen.main()
|
|
209
|
+
assert exc_info.value.code == 1
|
|
210
|
+
|
|
211
|
+
def test_missing_file_exits(self, monkeypatch, capsys):
|
|
212
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", "missing.py"])
|
|
213
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
214
|
+
gen.main()
|
|
215
|
+
assert exc_info.value.code == 1
|
|
216
|
+
assert "File not found" in capsys.readouterr().out
|
|
217
|
+
|
|
218
|
+
def test_directory_source_exits(self, monkeypatch, tmp_path, capsys):
|
|
219
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(tmp_path)])
|
|
220
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
221
|
+
gen.main()
|
|
222
|
+
assert exc_info.value.code == 1
|
|
223
|
+
assert "Source path is a directory" in capsys.readouterr().out
|
|
224
|
+
|
|
225
|
+
def test_dry_run_and_write(self, monkeypatch, tmp_path, capsys):
|
|
226
|
+
src = tmp_path / "mod.py"
|
|
227
|
+
src.write_text("def hello():\n return 1\n", encoding="utf-8")
|
|
228
|
+
|
|
229
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src), "--dry-run"])
|
|
230
|
+
gen.main()
|
|
231
|
+
assert "Generated test template" in capsys.readouterr().out
|
|
232
|
+
|
|
233
|
+
out_root = tmp_path / "generated"
|
|
234
|
+
monkeypatch.setattr(
|
|
235
|
+
sys,
|
|
236
|
+
"argv",
|
|
237
|
+
["generate_tests.py", str(src), "--output-root", str(out_root)],
|
|
238
|
+
)
|
|
239
|
+
gen.main()
|
|
240
|
+
# output-root puts files under <output-root>/<category>
|
|
241
|
+
assert (out_root / "unit" / "test_mod.py").exists()
|
|
242
|
+
|
|
243
|
+
def test_scan_mode_all_modules_have_tests_branch(self, monkeypatch, capsys):
|
|
244
|
+
class _Discovery:
|
|
245
|
+
def find_untested(self, _scan_target=None):
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(gen, "TestDiscovery", lambda: _Discovery())
|
|
249
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", "--scan"])
|
|
250
|
+
gen.main()
|
|
251
|
+
assert "All modules have tests" in capsys.readouterr().out
|
|
252
|
+
|
|
253
|
+
def test_no_entities_and_existing_file_branches(
|
|
254
|
+
self,
|
|
255
|
+
monkeypatch,
|
|
256
|
+
tmp_path,
|
|
257
|
+
capsys,
|
|
258
|
+
):
|
|
259
|
+
src = tmp_path / "m.py"
|
|
260
|
+
src.write_text("x=1\n", encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
class _Analyzer:
|
|
263
|
+
def __init__(self, *_a, **_k):
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
def analyze(self):
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
monkeypatch.setattr(gen, "CodeAnalyzer", _Analyzer)
|
|
270
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src)])
|
|
271
|
+
gen.main()
|
|
272
|
+
assert "No testable entities found" in capsys.readouterr().out
|
|
273
|
+
|
|
274
|
+
class _Analyzer2:
|
|
275
|
+
def __init__(self, *_a, **_k):
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
def analyze(self):
|
|
279
|
+
return [("hello", "function")]
|
|
280
|
+
|
|
281
|
+
monkeypatch.setattr(gen, "CodeAnalyzer", _Analyzer2)
|
|
282
|
+
out_root = tmp_path / "out"
|
|
283
|
+
out_root.mkdir()
|
|
284
|
+
existing = out_root / "unit" / "test_m.py"
|
|
285
|
+
existing.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
existing.write_text("# preexisting\n", encoding="utf-8")
|
|
287
|
+
monkeypatch.setattr(
|
|
288
|
+
sys,
|
|
289
|
+
"argv",
|
|
290
|
+
[
|
|
291
|
+
"generate_tests.py",
|
|
292
|
+
str(src),
|
|
293
|
+
"--output-root",
|
|
294
|
+
str(out_root),
|
|
295
|
+
],
|
|
296
|
+
)
|
|
297
|
+
gen.main()
|
|
298
|
+
assert "File exists" in capsys.readouterr().out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_generate_tests_dunder_main(monkeypatch, tmp_path):
|
|
302
|
+
src = tmp_path / "sample_mod.py"
|
|
303
|
+
src.write_text("def hello():\n return 1\n", encoding="utf-8")
|
|
304
|
+
monkeypatch.setattr(sys, "argv", ["generate_tests.py", str(src), "--dry-run"])
|
|
305
|
+
runpy.run_path("scripts/generate_tests.py", run_name="__main__")
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Regression tests for collab git hook templates and installer overlay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import textwrap
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from tests.backend.unit.scripts._helpers import ROOT
|
|
15
|
+
|
|
16
|
+
GIT_SH_CANDIDATES = [
|
|
17
|
+
shutil.which("sh"),
|
|
18
|
+
r"C:\Users\kmartineztamayo\AppData\Local\Programs\Git\bin\sh.exe",
|
|
19
|
+
r"C:\Program Files\Git\bin\sh.exe",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(scope="module")
|
|
24
|
+
def git_sh() -> str:
|
|
25
|
+
"""Return a shell executable compatible with git hook scripts."""
|
|
26
|
+
for candidate in GIT_SH_CANDIDATES:
|
|
27
|
+
if candidate and Path(candidate).exists():
|
|
28
|
+
return candidate
|
|
29
|
+
pytest.skip("Git shell executable not available for hook template tests")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture()
|
|
33
|
+
def hook_repo(tmp_path: Path) -> Path:
|
|
34
|
+
"""Create a temporary git repo with minimal collab hook runtime."""
|
|
35
|
+
repo = tmp_path / "repo"
|
|
36
|
+
repo.mkdir()
|
|
37
|
+
|
|
38
|
+
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["git", "config", "user.name", "Hook Tester"],
|
|
41
|
+
cwd=repo,
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
)
|
|
45
|
+
subprocess.run(
|
|
46
|
+
["git", "config", "user.email", "hook-tester@example.com"],
|
|
47
|
+
cwd=repo,
|
|
48
|
+
check=True,
|
|
49
|
+
capture_output=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
(repo / "hooks").mkdir(parents=True)
|
|
53
|
+
(repo / ".venv" / "bin").mkdir(parents=True)
|
|
54
|
+
(repo / "scripts").mkdir(parents=True)
|
|
55
|
+
(repo / ".git" / "hooks").mkdir(parents=True, exist_ok=True)
|
|
56
|
+
(repo / ".pre-commit-config.yaml").write_text("repos: []\n", encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
for hook_name in ("pre-commit", "post-commit", "pre-push", "commit-msg"):
|
|
59
|
+
source = ROOT / "hooks" / hook_name
|
|
60
|
+
target = repo / "hooks" / hook_name
|
|
61
|
+
target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
install_source = ROOT / "install_hooks.sh"
|
|
64
|
+
install_target = repo / "install_hooks.sh"
|
|
65
|
+
install_target.write_text(
|
|
66
|
+
install_source.read_text(encoding="utf-8"),
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
fake_python = repo / ".venv" / "bin" / "python"
|
|
71
|
+
# Use the absolute path of the running interpreter so that when the
|
|
72
|
+
# pre-push hook prepends .venv/bin to PATH, `python` never resolves
|
|
73
|
+
# back to this wrapper script causing an infinite self-referential loop.
|
|
74
|
+
real_python = sys.executable.replace("\\", "/")
|
|
75
|
+
fake_python.write_text(
|
|
76
|
+
textwrap.dedent(f"""
|
|
77
|
+
#!/bin/sh
|
|
78
|
+
exec "{real_python}" "$@"
|
|
79
|
+
""").lstrip(),
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
fake_python.chmod(0o755)
|
|
83
|
+
|
|
84
|
+
fake_pre_commit = repo / ".venv" / "bin" / "pre-commit"
|
|
85
|
+
fake_pre_commit.write_text(
|
|
86
|
+
textwrap.dedent("""
|
|
87
|
+
#!/bin/sh
|
|
88
|
+
stage=""
|
|
89
|
+
while [ $# -gt 0 ]; do
|
|
90
|
+
if [ "$1" = "--hook-stage" ]; then
|
|
91
|
+
stage="$2"
|
|
92
|
+
shift 2
|
|
93
|
+
continue
|
|
94
|
+
fi
|
|
95
|
+
shift
|
|
96
|
+
done
|
|
97
|
+
printf '[fake-pre-commit] %s\n' "$stage"
|
|
98
|
+
if [ "$stage" = "pre-push" ] && [ -n "$FAKE_PRE_PUSH_FAIL" ]; then
|
|
99
|
+
exit 1
|
|
100
|
+
fi
|
|
101
|
+
exit 0
|
|
102
|
+
""").lstrip(),
|
|
103
|
+
encoding="utf-8",
|
|
104
|
+
)
|
|
105
|
+
fake_pre_commit.chmod(0o755)
|
|
106
|
+
|
|
107
|
+
collab_hook_helper = repo / "scripts" / "collab_git_hook.py"
|
|
108
|
+
collab_hook_helper.write_text(
|
|
109
|
+
textwrap.dedent("""
|
|
110
|
+
import os
|
|
111
|
+
import sys
|
|
112
|
+
|
|
113
|
+
command = sys.argv[1]
|
|
114
|
+
if command == "acquire-staged":
|
|
115
|
+
mode = os.getenv("FAKE_ACQUIRE_MODE", "watcher")
|
|
116
|
+
if mode == "watcher":
|
|
117
|
+
message = (
|
|
118
|
+
"[collab] Watcher running (PID: 3500) "
|
|
119
|
+
"— skipping pre-commit lock acquisition."
|
|
120
|
+
)
|
|
121
|
+
print(
|
|
122
|
+
message,
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
raise SystemExit(0)
|
|
126
|
+
if mode == "conflict":
|
|
127
|
+
print(
|
|
128
|
+
"[collab] Commit blocked due to lock conflicts:",
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
print(" - conflicted.txt (locked by @otherdev)", file=sys.stderr)
|
|
132
|
+
raise SystemExit(1)
|
|
133
|
+
print("[collab] Locks acquired for 1 staged file(s).", file=sys.stderr)
|
|
134
|
+
raise SystemExit(0)
|
|
135
|
+
if command == "release-all":
|
|
136
|
+
count = os.getenv("FAKE_RELEASE_COUNT", "1")
|
|
137
|
+
print(f"[collab] Released {count} lock(s).", file=sys.stderr)
|
|
138
|
+
raise SystemExit(0)
|
|
139
|
+
raise SystemExit(2)
|
|
140
|
+
""").lstrip(),
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return repo
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _run_sh(
|
|
148
|
+
script: Path,
|
|
149
|
+
shell_path: str,
|
|
150
|
+
cwd: Path,
|
|
151
|
+
env: dict[str, str] | None = None,
|
|
152
|
+
) -> subprocess.CompletedProcess[str]:
|
|
153
|
+
run_env = os.environ.copy()
|
|
154
|
+
if env:
|
|
155
|
+
run_env.update(env)
|
|
156
|
+
return subprocess.run(
|
|
157
|
+
[shell_path, str(script)],
|
|
158
|
+
cwd=cwd,
|
|
159
|
+
env=run_env,
|
|
160
|
+
stdin=subprocess.DEVNULL,
|
|
161
|
+
capture_output=True,
|
|
162
|
+
text=True,
|
|
163
|
+
check=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _normalized_output(result: subprocess.CompletedProcess[str]) -> str:
|
|
168
|
+
"""Normalize common Git Bash mojibake so assertions stay stable on Windows."""
|
|
169
|
+
return (result.stdout + result.stderr).replace("—", "—")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_install_hooks_copies_templates_into_git_hooks(hook_repo: Path, git_sh: str):
|
|
173
|
+
result = _run_sh(hook_repo / "install_hooks.sh", git_sh, hook_repo)
|
|
174
|
+
|
|
175
|
+
assert result.returncode == 0
|
|
176
|
+
assert "Installed git hooks from hooks/" in result.stdout
|
|
177
|
+
for hook_name in ("pre-commit", "post-commit", "pre-push", "commit-msg"):
|
|
178
|
+
expected = (hook_repo / "hooks" / hook_name).read_text(encoding="utf-8")
|
|
179
|
+
actual = (hook_repo / ".git" / "hooks" / hook_name).read_text(encoding="utf-8")
|
|
180
|
+
assert actual == expected
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_pre_commit_hook_prints_watcher_message_then_runs_framework(
|
|
184
|
+
hook_repo: Path,
|
|
185
|
+
git_sh: str,
|
|
186
|
+
):
|
|
187
|
+
staged = hook_repo / "tracked.txt"
|
|
188
|
+
staged.write_text("content\n", encoding="utf-8")
|
|
189
|
+
subprocess.run(["git", "add", "tracked.txt"], cwd=hook_repo, check=True)
|
|
190
|
+
|
|
191
|
+
result = _run_sh(
|
|
192
|
+
hook_repo / "hooks" / "pre-commit",
|
|
193
|
+
git_sh,
|
|
194
|
+
hook_repo,
|
|
195
|
+
env={"SKIP": "validate-code"},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert result.returncode == 0
|
|
199
|
+
combined = _normalized_output(result)
|
|
200
|
+
assert (
|
|
201
|
+
"[collab] Watcher running (PID: 3500) — skipping pre-commit lock acquisition."
|
|
202
|
+
in combined
|
|
203
|
+
)
|
|
204
|
+
assert "[fake-pre-commit] pre-commit" in combined
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_pre_commit_hook_blocks_on_conflict(hook_repo: Path, git_sh: str):
|
|
208
|
+
staged = hook_repo / "conflicted.txt"
|
|
209
|
+
staged.write_text("content\n", encoding="utf-8")
|
|
210
|
+
subprocess.run(["git", "add", "conflicted.txt"], cwd=hook_repo, check=True)
|
|
211
|
+
|
|
212
|
+
result = _run_sh(
|
|
213
|
+
hook_repo / "hooks" / "pre-commit",
|
|
214
|
+
git_sh,
|
|
215
|
+
hook_repo,
|
|
216
|
+
env={"FAKE_ACQUIRE_MODE": "conflict"},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
assert result.returncode == 1
|
|
220
|
+
combined = _normalized_output(result)
|
|
221
|
+
assert "[collab] Commit blocked due to lock conflicts:" in combined
|
|
222
|
+
assert "conflicted.txt" in combined
|
|
223
|
+
assert "[collab] Commit aborted due to file lock conflicts." in combined
|
|
224
|
+
assert "[fake-pre-commit] pre-commit" not in combined
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_post_commit_hook_prints_message_and_chains_framework(
|
|
228
|
+
hook_repo: Path,
|
|
229
|
+
git_sh: str,
|
|
230
|
+
):
|
|
231
|
+
result = _run_sh(
|
|
232
|
+
hook_repo / "hooks" / "post-commit",
|
|
233
|
+
git_sh,
|
|
234
|
+
hook_repo,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
assert result.returncode == 0
|
|
238
|
+
combined = _normalized_output(result)
|
|
239
|
+
assert (
|
|
240
|
+
"[collab] Commit detected. Locks remain active until files are pushed."
|
|
241
|
+
in combined
|
|
242
|
+
)
|
|
243
|
+
assert "[fake-pre-commit] post-commit" in combined
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_pre_push_hook_keeps_locks_when_validation_fails(hook_repo: Path, git_sh: str):
|
|
247
|
+
result = _run_sh(
|
|
248
|
+
hook_repo / "hooks" / "pre-push",
|
|
249
|
+
git_sh,
|
|
250
|
+
hook_repo,
|
|
251
|
+
env={"FAKE_PRE_PUSH_FAIL": "1"},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
assert result.returncode == 1
|
|
255
|
+
combined = _normalized_output(result)
|
|
256
|
+
assert "[fake-pre-commit] pre-push" in combined
|
|
257
|
+
assert "[collab] Pre-push validation failed - keeping locks active." in combined
|
|
258
|
+
assert (
|
|
259
|
+
"[collab] Releasing all locks after successful pre-push validation..."
|
|
260
|
+
not in combined
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_pre_push_hook_releases_locks_after_success(hook_repo: Path, git_sh: str):
|
|
265
|
+
result = _run_sh(
|
|
266
|
+
hook_repo / "hooks" / "pre-push",
|
|
267
|
+
git_sh,
|
|
268
|
+
hook_repo,
|
|
269
|
+
env={"FAKE_RELEASE_COUNT": "4"},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
assert result.returncode == 0
|
|
273
|
+
combined = _normalized_output(result)
|
|
274
|
+
assert "[fake-pre-commit] pre-push" in combined
|
|
275
|
+
assert (
|
|
276
|
+
"[collab] Releasing all locks after successful pre-push validation..."
|
|
277
|
+
in combined
|
|
278
|
+
)
|
|
279
|
+
assert "[collab] Released 4 lock(s)." in combined
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _run_sh_with_arg(
|
|
283
|
+
script: Path,
|
|
284
|
+
shell_path: str,
|
|
285
|
+
cwd: Path,
|
|
286
|
+
arg: str,
|
|
287
|
+
env: dict[str, str] | None = None,
|
|
288
|
+
) -> subprocess.CompletedProcess[str]:
|
|
289
|
+
run_env = os.environ.copy()
|
|
290
|
+
if env:
|
|
291
|
+
run_env.update(env)
|
|
292
|
+
return subprocess.run(
|
|
293
|
+
[shell_path, str(script), arg],
|
|
294
|
+
cwd=cwd,
|
|
295
|
+
env=run_env,
|
|
296
|
+
stdin=subprocess.DEVNULL,
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
check=False,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_commit_msg_hook_passes_valid_conventional_commit(hook_repo: Path, git_sh: str):
|
|
304
|
+
msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
|
|
305
|
+
msg_file.write_text("feat(core): add new feature\n", encoding="utf-8")
|
|
306
|
+
|
|
307
|
+
result = _run_sh_with_arg(
|
|
308
|
+
hook_repo / "hooks" / "commit-msg",
|
|
309
|
+
git_sh,
|
|
310
|
+
hook_repo,
|
|
311
|
+
arg=str(msg_file),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
assert result.returncode == 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_commit_msg_hook_blocks_invalid_message(hook_repo: Path, git_sh: str):
|
|
318
|
+
msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
|
|
319
|
+
msg_file.write_text("added some stuff\n", encoding="utf-8")
|
|
320
|
+
|
|
321
|
+
result = _run_sh_with_arg(
|
|
322
|
+
hook_repo / "hooks" / "commit-msg",
|
|
323
|
+
git_sh,
|
|
324
|
+
hook_repo,
|
|
325
|
+
arg=str(msg_file),
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
assert result.returncode == 1
|
|
329
|
+
assert "Conventional Commits" in result.stderr
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_commit_msg_hook_allows_merge_commit(hook_repo: Path, git_sh: str):
|
|
333
|
+
msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
|
|
334
|
+
msg_file.write_text("Merge branch 'main' into feature\n", encoding="utf-8")
|
|
335
|
+
|
|
336
|
+
result = _run_sh_with_arg(
|
|
337
|
+
hook_repo / "hooks" / "commit-msg",
|
|
338
|
+
git_sh,
|
|
339
|
+
hook_repo,
|
|
340
|
+
arg=str(msg_file),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
assert result.returncode == 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_commit_msg_hook_allows_fixup_commit(hook_repo: Path, git_sh: str):
|
|
347
|
+
msg_file = hook_repo / ".git" / "COMMIT_EDITMSG"
|
|
348
|
+
msg_file.write_text("fixup! feat(core): add new feature\n", encoding="utf-8")
|
|
349
|
+
|
|
350
|
+
result = _run_sh_with_arg(
|
|
351
|
+
hook_repo / "hooks" / "commit-msg",
|
|
352
|
+
git_sh,
|
|
353
|
+
hook_repo,
|
|
354
|
+
arg=str(msg_file),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
assert result.returncode == 0
|