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.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
@@ -0,0 +1,301 @@
1
+ """History-related tests for LockClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from ._helpers import FakeResponse, load_lock_client_module, make_create_client
8
+
9
+ mod = load_lock_client_module()
10
+
11
+
12
+ def test_history_all_files(monkeypatch):
13
+ """Test fetching lock history for all files."""
14
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
15
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
16
+
17
+ history_data = [
18
+ {"file_path": "src/app.py", "developer_id": "user1", "action": "acquired"},
19
+ {"file_path": "src/app.py", "developer_id": "user1", "action": "released"},
20
+ ]
21
+ response = FakeResponse(status=200, data=history_data)
22
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
23
+
24
+ lc = mod.LockClient(developer_id="test_user")
25
+ history = lc.history()
26
+ assert isinstance(history, list)
27
+
28
+
29
+ def test_history_specific_file(monkeypatch):
30
+ """Test fetching lock history for a specific file."""
31
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
32
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
33
+
34
+ history_data = [
35
+ {"file_path": "src/app.py", "developer_id": "user1", "action": "acquired"}
36
+ ]
37
+ response = FakeResponse(status=200, data=history_data)
38
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
39
+
40
+ lc = mod.LockClient(developer_id="test_user")
41
+ history = lc.history(file_path="src/app.py", limit=10)
42
+ assert isinstance(history, list)
43
+
44
+
45
+ def test_history_exception(monkeypatch):
46
+ """Test history returns empty list when API raises exception."""
47
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
48
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
49
+
50
+ class ExplodingClient:
51
+ def __init__(self, resp):
52
+ self._resp = resp
53
+
54
+ def table(self, *a, **k):
55
+ raise RuntimeError("DB error")
56
+
57
+ monkeypatch.setattr(
58
+ mod, "_get_create_client", lambda: (lambda url, key: ExplodingClient(None))
59
+ )
60
+
61
+ lc = mod.LockClient(developer_id="test_user")
62
+ history = lc.history()
63
+ assert history == []
64
+
65
+
66
+ # RESTORED: test_history_fallback_exception
67
+ def test_history_fallback_exception(monkeypatch):
68
+ """Test history partial exception."""
69
+ monkeypatch.setattr(mod, "SUPABASE_SERVICE_ROLE_KEY", "admin_key")
70
+ monkeypatch.setattr(mod, "_supabase_create_client", lambda url, key: None)
71
+
72
+ class FakeQuery:
73
+ def select(self, *args):
74
+ return self
75
+
76
+ def eq(self, *args):
77
+ return self
78
+
79
+ def is_(self, *args):
80
+ return self
81
+
82
+ def ilike(self, *args):
83
+ return self
84
+
85
+ def order(self, *args, **kwargs):
86
+ return self
87
+
88
+ def limit(self, *args):
89
+ return self
90
+
91
+ def execute(self):
92
+ return None
93
+
94
+ class FakeClient:
95
+ def table(self, *args):
96
+ return FakeQuery()
97
+
98
+ client = getattr(mod, "LockClient")()
99
+ client._client = FakeClient()
100
+ monkeypatch.setattr(client, "_parse_response", lambda res: (False, [], None))
101
+
102
+ # Pass an object that throws when string methods are called
103
+ class Exploder:
104
+ def replace(self, *args, **kwargs):
105
+ raise Exception("boom")
106
+
107
+ res = client.history(limit=5, file_path=Exploder())
108
+ assert res == []
109
+
110
+
111
+ def test_prune_history_rejects_invalid_retention(monkeypatch):
112
+ """prune_history should reject retention values < 1."""
113
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
114
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
115
+ monkeypatch.setattr(
116
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
117
+ )
118
+
119
+ lc = mod.LockClient(developer_id="test_user")
120
+ ok, deleted, msg = lc.prune_history(retention_days=0)
121
+
122
+ assert ok is False
123
+ assert deleted == 0
124
+ assert ">= 1" in msg
125
+
126
+
127
+ def test_prune_history_rpc_success(monkeypatch):
128
+ """prune_history should return deleted count from RPC when available."""
129
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
130
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
131
+ monkeypatch.setattr(
132
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
133
+ )
134
+
135
+ class RpcClient:
136
+ def rpc(self, *args, **kwargs):
137
+ return self
138
+
139
+ def execute(self):
140
+ return None
141
+
142
+ lc = mod.LockClient(developer_id="test_user")
143
+ lc._client = RpcClient()
144
+ monkeypatch.setattr(
145
+ lc, "_parse_response", lambda _res: (200, [{"prune_lock_history": 7}], None)
146
+ )
147
+ monkeypatch.setattr(mod, "_retry_on_network_error", lambda fn: fn())
148
+
149
+ ok, deleted, msg = lc.prune_history(retention_days=30)
150
+
151
+ assert ok is True
152
+ assert deleted == 7
153
+ assert msg == "history-pruned"
154
+
155
+
156
+ def test_prune_history_fallback_success(monkeypatch):
157
+ """prune_history should fall back to REST delete when RPC is unavailable."""
158
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
159
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
160
+ monkeypatch.setattr(
161
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
162
+ )
163
+
164
+ class FallbackQuery:
165
+ def __init__(self):
166
+ self.lt_called = False
167
+
168
+ def delete(self):
169
+ return self
170
+
171
+ def lt(self, *_args, **_kwargs):
172
+ self.lt_called = True
173
+ return self
174
+
175
+ def execute(self):
176
+ return None
177
+
178
+ class FallbackClient:
179
+ def __init__(self):
180
+ self.query = FallbackQuery()
181
+
182
+ def rpc(self, *args, **kwargs):
183
+ raise RuntimeError("missing RPC")
184
+
185
+ def table(self, *args, **kwargs):
186
+ return self.query
187
+
188
+ lc = mod.LockClient(developer_id="test_user")
189
+ fake_client = FallbackClient()
190
+ lc._client = fake_client
191
+ monkeypatch.setattr(mod, "_retry_on_network_error", lambda fn: fn())
192
+ monkeypatch.setattr(
193
+ lc, "_parse_response", lambda _res: (200, [{"id": 1}, {"id": 2}], None)
194
+ )
195
+
196
+ ok, deleted, msg = lc.prune_history(retention_days=30)
197
+
198
+ assert ok is True
199
+ assert deleted == 2
200
+ assert msg == "history-pruned-fallback"
201
+ assert fake_client.query.lt_called is True
202
+
203
+
204
+ def test_prune_history_fallback_api_error(monkeypatch):
205
+ """prune_history should surface API errors from fallback delete path."""
206
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
207
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
208
+ monkeypatch.setattr(
209
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
210
+ )
211
+
212
+ class FallbackQuery:
213
+ def delete(self):
214
+ return self
215
+
216
+ def lt(self, *_args, **_kwargs):
217
+ return self
218
+
219
+ def execute(self):
220
+ return None
221
+
222
+ class FallbackClient:
223
+ def rpc(self, *args, **kwargs):
224
+ raise RuntimeError("missing RPC")
225
+
226
+ def table(self, *args, **kwargs):
227
+ return FallbackQuery()
228
+
229
+ lc = mod.LockClient(developer_id="test_user")
230
+ lc._client = FallbackClient()
231
+ monkeypatch.setattr(mod, "_retry_on_network_error", lambda fn: fn())
232
+ monkeypatch.setattr(
233
+ lc, "_parse_response", lambda _res: (500, None, {"message": "boom"})
234
+ )
235
+
236
+ ok, deleted, msg = lc.prune_history(retention_days=30)
237
+
238
+ assert ok is False
239
+ assert deleted == 0
240
+ assert "API Error" in msg
241
+
242
+
243
+ @pytest.mark.parametrize(
244
+ ("rpc_data", "expected_deleted"),
245
+ [
246
+ ([{"prune_lock_history": "not-an-int", "count": "3"}], 3),
247
+ ([11], 11),
248
+ (9, 9),
249
+ ],
250
+ )
251
+ def test_prune_history_rpc_data_shapes(monkeypatch, rpc_data, expected_deleted):
252
+ """prune_history should parse multiple RPC return shapes robustly."""
253
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
254
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
255
+ monkeypatch.setattr(
256
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
257
+ )
258
+
259
+ class RpcClient:
260
+ def rpc(self, *args, **kwargs):
261
+ return self
262
+
263
+ def execute(self):
264
+ return None
265
+
266
+ lc = mod.LockClient(developer_id="test_user")
267
+ lc._client = RpcClient()
268
+ monkeypatch.setattr(lc, "_parse_response", lambda _res: (200, rpc_data, None))
269
+ monkeypatch.setattr(mod, "_retry_on_network_error", lambda fn: fn())
270
+
271
+ ok, deleted, msg = lc.prune_history(retention_days=30)
272
+
273
+ assert ok is True
274
+ assert deleted == expected_deleted
275
+ assert msg == "history-pruned"
276
+
277
+
278
+ def test_prune_history_fallback_exception_path(monkeypatch):
279
+ """prune_history should return API Error when fallback delete raises."""
280
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
281
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
282
+ monkeypatch.setattr(
283
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
284
+ )
285
+
286
+ class FallbackClient:
287
+ def rpc(self, *args, **kwargs):
288
+ raise RuntimeError("missing RPC")
289
+
290
+ def table(self, *args, **kwargs):
291
+ raise RuntimeError("delete boom")
292
+
293
+ lc = mod.LockClient(developer_id="test_user")
294
+ lc._client = FallbackClient()
295
+ monkeypatch.setattr(mod, "_retry_on_network_error", lambda fn: fn())
296
+
297
+ ok, deleted, msg = lc.prune_history(retention_days=30)
298
+
299
+ assert ok is False
300
+ assert deleted == 0
301
+ assert "delete boom" in msg
@@ -0,0 +1,316 @@
1
+ """Comprehensive tests for production/test environment isolation.
2
+
3
+ This test module ensures that:
4
+ 1. Test daemons never interfere with production daemons
5
+ 2. Test discovery filters don't match production watcher processes
6
+ 3. PID files are isolated and scoped by namespace
7
+ 4. Tests cannot kill or affect production locks
8
+
9
+ This prevents the recurring issue where pytest runs would terminate the
10
+ production daemon or cause status flips due to cross-namespace discovery.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import types
17
+ from pathlib import Path
18
+
19
+ from ._helpers import load_lock_client_module
20
+
21
+
22
+ def test_namespace_filter_rejects_mismatched_watcher(monkeypatch, tmp_path):
23
+ """Discovery must reject watcher processes without matching PID namespace tag."""
24
+ mod = load_lock_client_module()
25
+
26
+ # Set up test client with isolated PID file
27
+ test_pid_file = str(tmp_path / "test.pid")
28
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
29
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
30
+
31
+ # Mock psutil to return a watcher WITHOUT the --pid-file tag
32
+ # (simulating a legacy or production watcher)
33
+ class FakeProc:
34
+ def __init__(self, pid, cmdline):
35
+ self.info = {"pid": pid, "cmdline": cmdline}
36
+
37
+ def fake_process_iter(attrs=("pid", "cmdline")):
38
+ return [
39
+ # Production watcher WITHOUT namespace tag - should be rejected
40
+ FakeProc(
41
+ 5555,
42
+ [
43
+ "python",
44
+ ".collab/pycharm/live_locks_watcher.py",
45
+ # NO --pid-file tag
46
+ ],
47
+ ),
48
+ # Test watcher WITH matching namespace tag - should be accepted
49
+ FakeProc(
50
+ 6666,
51
+ [
52
+ "python",
53
+ ".collab/pycharm/live_locks_watcher.py",
54
+ "--pid-file",
55
+ test_pid_file,
56
+ ],
57
+ ),
58
+ ]
59
+
60
+ fake_psutil = types.SimpleNamespace(process_iter=fake_process_iter)
61
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
62
+
63
+ client = mod.LockClient(local_only=True)
64
+ found_pids = client._discover_running_watchers()
65
+
66
+ # Should ONLY find the namespaced test watcher (6666)
67
+ # Should REJECT the untagged production watcher (5555)
68
+ assert 6666 in found_pids, "Namespaced test watcher must be discovered"
69
+ assert (
70
+ 5555 not in found_pids
71
+ ), "Unnamespaced production watcher must be filtered out in test mode"
72
+
73
+
74
+ def test_pid_file_namespace_tag_round_trip(monkeypatch, tmp_path):
75
+ """PID file namespace tag must survive write/read cycle correctly."""
76
+ mod = load_lock_client_module()
77
+
78
+ test_pid_file = str(tmp_path / "roundtrip.pid")
79
+ test_pid = 9999
80
+
81
+ # Write with namespace metadata
82
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
83
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
84
+ monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path))
85
+
86
+ # Simulate daemon writing PID with namespace tag
87
+ pid_meta = {
88
+ "pid": test_pid,
89
+ "started_at": "2026-05-02T10:00:00Z",
90
+ "entrypoint": "python lock_client.py",
91
+ "cmdline": (
92
+ f"python .collab/pycharm/live_locks_watcher.py "
93
+ f"--pid-file {test_pid_file}"
94
+ ),
95
+ "cwd": os.getcwd(),
96
+ }
97
+ Path(test_pid_file).write_text(json.dumps(pid_meta), encoding="utf-8")
98
+
99
+ # Read and verify namespace tag is present
100
+ read_data = json.loads(Path(test_pid_file).read_text(encoding="utf-8"))
101
+ assert "cmdline" in read_data
102
+ assert f"--pid-file {test_pid_file}" in read_data["cmdline"]
103
+ assert read_data["pid"] == test_pid
104
+
105
+
106
+ def test_conflicting_pid_files_are_independent(monkeypatch, tmp_path):
107
+ """Test and prod PID files must be independent, not affected by deletion."""
108
+ load_lock_client_module()
109
+
110
+ test_pid_file = str(tmp_path / "test_independent.pid")
111
+ prod_pid_file = str(tmp_path / "prod_independent.pid")
112
+
113
+ # Create both files
114
+ Path(test_pid_file).write_text("1111", encoding="utf-8")
115
+ Path(prod_pid_file).write_text("2222", encoding="utf-8")
116
+
117
+ assert Path(test_pid_file).exists()
118
+ assert Path(prod_pid_file).exists()
119
+
120
+ # Delete test PID file (simulating test cleanup)
121
+ Path(test_pid_file).unlink()
122
+
123
+ # Production PID file must remain untouched
124
+ assert not Path(test_pid_file).exists()
125
+ assert Path(prod_pid_file).exists()
126
+ assert Path(prod_pid_file).read_text(encoding="utf-8") == "2222"
127
+
128
+
129
+ def test_discover_watchers_filters_by_strict_namespace(monkeypatch, tmp_path):
130
+ """In test mode, discover_running_watchers uses strict namespace filtering."""
131
+ mod = load_lock_client_module()
132
+
133
+ test_pid_file = str(tmp_path / "strict_ns.pid")
134
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
135
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
136
+
137
+ class FakeProc:
138
+ def __init__(self, pid, cmdline):
139
+ self.info = {"pid": pid, "cmdline": cmdline}
140
+
141
+ # Multiple watchers with different or missing namespace tags
142
+ def fake_process_iter(attrs=("pid", "cmdline")):
143
+ return [
144
+ # No tag at all
145
+ FakeProc(1111, ["python", ".collab/pycharm/live_locks_watcher.py"]),
146
+ # Wrong namespace tag
147
+ FakeProc(
148
+ 2222,
149
+ [
150
+ "python",
151
+ ".collab/pycharm/live_locks_watcher.py",
152
+ "--pid-file",
153
+ "/other/path.pid",
154
+ ],
155
+ ),
156
+ # Correct namespace tag
157
+ FakeProc(
158
+ 3333,
159
+ [
160
+ "python",
161
+ ".collab/pycharm/live_locks_watcher.py",
162
+ "--pid-file",
163
+ test_pid_file,
164
+ ],
165
+ ),
166
+ ]
167
+
168
+ fake_psutil = types.SimpleNamespace(process_iter=fake_process_iter)
169
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
170
+
171
+ client = mod.LockClient(local_only=True)
172
+ found = client._discover_running_watchers()
173
+
174
+ # Only correct namespace should match
175
+ assert 3333 in found
176
+ assert 1111 not in found
177
+ assert 2222 not in found
178
+
179
+
180
+ def test_extract_pid_file_from_cmdline_parsing(monkeypatch, tmp_path):
181
+ """Verify _extract_pid_file_from_cmdline correctly parses namespace tags."""
182
+ mod = load_lock_client_module()
183
+
184
+ client = mod.LockClient(local_only=True)
185
+
186
+ # Test various cmdline formats
187
+ test_path = str(tmp_path / "test.pid")
188
+
189
+ # Standard format with --pid-file flag
190
+ cmdline = f"python watcher.py --pid-file {test_path}"
191
+ extracted = client._extract_pid_file_from_cmdline(cmdline)
192
+ assert extracted == test_path
193
+
194
+ # Without --pid-file flag
195
+ cmdline_no_flag = "python watcher.py"
196
+ extracted_no_flag = client._extract_pid_file_from_cmdline(cmdline_no_flag)
197
+ assert extracted_no_flag is None
198
+
199
+ # With other flags before --pid-file
200
+ cmdline_multi = f"python watcher.py --verbose --pid-file {test_path} --timeout 30"
201
+ extracted_multi = client._extract_pid_file_from_cmdline(cmdline_multi)
202
+ assert extracted_multi == test_path
203
+
204
+
205
+ def test_cmdline_matches_current_pid_namespace_validation(monkeypatch, tmp_path):
206
+ """Verify namespace matching only accepts properly scoped watchers."""
207
+ mod = load_lock_client_module()
208
+
209
+ test_pid_file = str(tmp_path / "validation.pid")
210
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
211
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
212
+
213
+ client = mod.LockClient(local_only=True)
214
+
215
+ # Matching namespace - should return True
216
+ matching_cmdline = f"python watcher.py --pid-file {test_pid_file}"
217
+ assert client._cmdline_matches_current_pid_namespace(matching_cmdline)
218
+
219
+ # Mismatched namespace - should return False
220
+ other_pid_file = str(tmp_path / "other.pid")
221
+ mismatched_cmdline = f"python watcher.py --pid-file {other_pid_file}"
222
+ assert not client._cmdline_matches_current_pid_namespace(mismatched_cmdline)
223
+
224
+ # No namespace tag in test mode - should return False (strict test isolation)
225
+ no_tag_cmdline = "python watcher.py"
226
+ assert not client._cmdline_matches_current_pid_namespace(no_tag_cmdline)
227
+
228
+
229
+ def test_isolation_prevents_cross_namespace_discovery(monkeypatch, tmp_path):
230
+ """Ensure test discovery cannot find production processes."""
231
+ mod = load_lock_client_module()
232
+
233
+ test_pid_file = str(tmp_path / "test_ns.pid")
234
+ prod_pid_file = str(tmp_path / "prod_ns.pid")
235
+
236
+ # Simulate two independent namespaces
237
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
238
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
239
+
240
+ class FakeProc:
241
+ def __init__(self, pid, cmdline):
242
+ self.info = {"pid": pid, "cmdline": cmdline}
243
+
244
+ # One client in test mode
245
+ def fake_iter_test(attrs=("pid", "cmdline")):
246
+ return [
247
+ # Test watcher (namespaced)
248
+ FakeProc(
249
+ 11111,
250
+ [
251
+ "python",
252
+ ".collab/pycharm/live_locks_watcher.py",
253
+ "--pid-file",
254
+ test_pid_file,
255
+ ],
256
+ ),
257
+ # Production watcher (different namespace)
258
+ FakeProc(
259
+ 22222,
260
+ [
261
+ "python",
262
+ ".collab/pycharm/live_locks_watcher.py",
263
+ "--pid-file",
264
+ prod_pid_file,
265
+ ],
266
+ ),
267
+ ]
268
+
269
+ fake_psutil = types.SimpleNamespace(process_iter=fake_iter_test)
270
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
271
+
272
+ # Test discovery should only find its own namespaced process
273
+ client = mod.LockClient(local_only=True)
274
+ found = client._discover_running_watchers()
275
+
276
+ assert 11111 in found, "Test watcher in same namespace must be found"
277
+ assert (
278
+ 22222 not in found
279
+ ), "Production watcher in different namespace must be filtered"
280
+
281
+
282
+ def test_pid_namespace_isolation_env_override(monkeypatch, tmp_path):
283
+ """Verify COLLAB_PID_FILE env var creates proper isolation."""
284
+ load_lock_client_module()
285
+
286
+ isolated_pid = str(tmp_path / "isolated.pid")
287
+ monkeypatch.setenv("COLLAB_PID_FILE", isolated_pid)
288
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
289
+
290
+ # Force reload of PID_FILE from environment
291
+ if "COLLAB_PID_FILE" in os.environ:
292
+ pid_file_to_use = os.environ["COLLAB_PID_FILE"]
293
+ assert pid_file_to_use == isolated_pid
294
+
295
+
296
+ def test_namespace_tag_propagation_in_watcher_spawn(monkeypatch, tmp_path):
297
+ """Verify daemon_start properly tags spawned watcher with --pid-file."""
298
+ mod = load_lock_client_module()
299
+
300
+ test_pid_file = str(tmp_path / "spawn_test.pid")
301
+ monkeypatch.setattr(mod, "PID_FILE", test_pid_file)
302
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
303
+
304
+ # Verify that the spawn command includes --pid-file parameter
305
+ mod.LockClient(local_only=True)
306
+
307
+ # The spawn logic should include --pid-file in the command
308
+ # (We test this indirectly by checking the module's spawn implementation
309
+ # includes the flag in daemon_start's subprocess call)
310
+ source = mod.__file__
311
+ with open(source, "r", encoding="utf-8") as f:
312
+ content = f.read()
313
+ # Verify daemon_start includes --pid-file when spawning watch
314
+ assert (
315
+ "--pid-file" in content
316
+ ), "daemon_start must include --pid-file when spawning watcher"
@@ -0,0 +1,75 @@
1
+ import json
2
+
3
+ from ._helpers import load_lock_client_module
4
+
5
+ mod = load_lock_client_module()
6
+
7
+
8
+ def test_read_pid_missing(tmp_path):
9
+ mod = load_lock_client_module()
10
+ # Point PID_FILE to a non-existent path
11
+ mod.PID_FILE = str(tmp_path / "missing.pid")
12
+ assert mod.LockClient._read_pid() is None
13
+
14
+
15
+ def test_read_pid_plain_integer(tmp_path):
16
+ mod = load_lock_client_module()
17
+ p = tmp_path / "plain.pid"
18
+ p.write_text("12345")
19
+ mod.PID_FILE = str(p)
20
+ assert mod.LockClient._read_pid() == 12345
21
+
22
+
23
+ def test_read_pid_json_and_invalid(tmp_path):
24
+ mod = load_lock_client_module()
25
+ p = tmp_path / "meta.pid"
26
+ p.write_text(json.dumps({"pid": 54321, "started": "now"}))
27
+ mod.PID_FILE = str(p)
28
+ assert mod.LockClient._read_pid() == 54321
29
+
30
+ # Invalid JSON should return None
31
+ p2 = tmp_path / "bad.pid"
32
+ p2.write_text("{not json")
33
+ mod.PID_FILE = str(p2)
34
+ assert mod.LockClient._read_pid() is None
35
+
36
+
37
+ def test_read_pid_file_method(tmp_path):
38
+ mod = load_lock_client_module()
39
+ p = tmp_path / "meta2.pid"
40
+ data = {"pid": 1111, "cwd": "C:/proj"}
41
+ p.write_text(json.dumps(data))
42
+ mod.PID_FILE = str(p)
43
+ client = mod.LockClient(local_only=True)
44
+ meta = client._read_pid_file()
45
+ assert isinstance(meta, dict)
46
+ assert meta.get("pid") == 1111
47
+
48
+
49
+ def test_write_pid_fallback_on_replace_failure(monkeypatch, tmp_path):
50
+ pid_file = tmp_path / "daemon.pid"
51
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
52
+
53
+ replace_called = [False]
54
+
55
+ def failing_replace(src, dst):
56
+ replace_called[0] = True
57
+ if not mod.os.path.exists(pid_file):
58
+ raise OSError("Access denied")
59
+
60
+ monkeypatch.setattr(mod.os, "replace", failing_replace)
61
+
62
+ mod.LockClient._write_pid(12345, parent_pid=678, token="tok123")
63
+ assert pid_file.exists()
64
+ data = json.loads(pid_file.read_text())
65
+ assert data["pid"] == 12345
66
+
67
+
68
+ def test_remove_pid_notest_mode(monkeypatch, tmp_path):
69
+ monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
70
+ pid_file = tmp_path / "daemon.pid"
71
+ pid_file.write_text("12345")
72
+ monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
73
+
74
+ mod.LockClient._remove_pid()
75
+ assert not pid_file.exists()