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,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()
|