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,459 @@
|
|
|
1
|
+
"""Graceful shutdown tests for live_locks_watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._helpers import load_watcher_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_graceful_shutdown_functionality(monkeypatch):
|
|
9
|
+
mod = load_watcher_module()
|
|
10
|
+
"""Test graceful shutdown cleans up resources."""
|
|
11
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
12
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
13
|
+
monkeypatch.setenv("DEVELOPER_ID", "test_dev")
|
|
14
|
+
|
|
15
|
+
if hasattr(mod, "_graceful_shutdown"):
|
|
16
|
+
try:
|
|
17
|
+
mod._graceful_shutdown()
|
|
18
|
+
except Exception:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_graceful_shutdown_with_valid_dev_id(monkeypatch, tmp_path):
|
|
23
|
+
mod = load_watcher_module()
|
|
24
|
+
"""Test _graceful_shutdown releases locks for clean files and removes PID file."""
|
|
25
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
26
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
27
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
28
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
29
|
+
|
|
30
|
+
pid_file = tmp_path / "watcher.pid"
|
|
31
|
+
pid_file.write_text("12345")
|
|
32
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
33
|
+
|
|
34
|
+
# Mock git status returning empty (all files clean all locks released)
|
|
35
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
|
|
36
|
+
|
|
37
|
+
class FakeTable:
|
|
38
|
+
def delete(self):
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
def select(self, *args):
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def eq(self, *args):
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def execute(self):
|
|
48
|
+
return type("R", (), {"data": []})()
|
|
49
|
+
|
|
50
|
+
class FakeSupaClient:
|
|
51
|
+
def table(self, name):
|
|
52
|
+
return FakeTable()
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
|
|
55
|
+
|
|
56
|
+
mod._graceful_shutdown()
|
|
57
|
+
assert not pid_file.exists()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_graceful_shutdown_with_error(monkeypatch, tmp_path):
|
|
61
|
+
mod = load_watcher_module()
|
|
62
|
+
"""Test _graceful_shutdown handles errors during lock release."""
|
|
63
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
64
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
65
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
66
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
67
|
+
|
|
68
|
+
pid_file = tmp_path / "watcher.pid"
|
|
69
|
+
pid_file.write_text("12345")
|
|
70
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
71
|
+
|
|
72
|
+
def exploding_client(url, key):
|
|
73
|
+
raise RuntimeError("Connection failed")
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(mod, "create_client", exploding_client)
|
|
76
|
+
|
|
77
|
+
mod._graceful_shutdown()
|
|
78
|
+
assert not pid_file.exists()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_graceful_shutdown_no_dev_id(monkeypatch, tmp_path):
|
|
82
|
+
mod = load_watcher_module()
|
|
83
|
+
"""Test _graceful_shutdown when DEVELOPER_ID is None."""
|
|
84
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", None)
|
|
85
|
+
|
|
86
|
+
pid_file = tmp_path / "watcher.pid"
|
|
87
|
+
pid_file.write_text("12345")
|
|
88
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
89
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
90
|
+
|
|
91
|
+
mod._graceful_shutdown()
|
|
92
|
+
assert not pid_file.exists()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_graceful_shutdown_pid_file_missing(monkeypatch, tmp_path):
|
|
96
|
+
mod = load_watcher_module()
|
|
97
|
+
"""Test _graceful_shutdown when PID file doesn't exist."""
|
|
98
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", None)
|
|
99
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "missing.pid"))
|
|
100
|
+
|
|
101
|
+
mod._graceful_shutdown() # Should not raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_graceful_shutdown_pid_oserror(monkeypatch, tmp_path):
|
|
105
|
+
mod = load_watcher_module()
|
|
106
|
+
"""Test _graceful_shutdown handles OSError when removing PID file."""
|
|
107
|
+
import os
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", None)
|
|
110
|
+
|
|
111
|
+
# Create a PID file path that will fail on os.remove
|
|
112
|
+
pid_file = tmp_path / "locked.pid"
|
|
113
|
+
pid_file.write_text("12345")
|
|
114
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
115
|
+
|
|
116
|
+
original_remove = os.remove
|
|
117
|
+
|
|
118
|
+
def failing_remove(path):
|
|
119
|
+
if "locked.pid" in str(path):
|
|
120
|
+
raise OSError("Permission denied")
|
|
121
|
+
return original_remove(path)
|
|
122
|
+
|
|
123
|
+
monkeypatch.setattr(os, "remove", failing_remove)
|
|
124
|
+
|
|
125
|
+
mod._graceful_shutdown() # Should not raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_graceful_shutdown_guard_prevents_double_run(monkeypatch, tmp_path):
|
|
129
|
+
mod = load_watcher_module()
|
|
130
|
+
"""_graceful_shutdown runs only once; second call is a no-op."""
|
|
131
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
132
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
133
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_tag")
|
|
134
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "pid"))
|
|
135
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
136
|
+
|
|
137
|
+
# Mock git status all clean
|
|
138
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
|
|
139
|
+
|
|
140
|
+
call_count = [0]
|
|
141
|
+
|
|
142
|
+
class FakeTable:
|
|
143
|
+
def delete(self):
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def select(self, *args):
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def eq(self, *args):
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def execute(self):
|
|
153
|
+
call_count[0] += 1
|
|
154
|
+
return type("R", (), {"data": []})()
|
|
155
|
+
|
|
156
|
+
class FakeClient:
|
|
157
|
+
def table(self, name):
|
|
158
|
+
return FakeTable()
|
|
159
|
+
|
|
160
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
|
|
161
|
+
|
|
162
|
+
mod._graceful_shutdown() # first call runs
|
|
163
|
+
first_count = call_count[0]
|
|
164
|
+
mod._graceful_shutdown() # second call guard returns immediately
|
|
165
|
+
|
|
166
|
+
assert call_count[0] == first_count # no additional calls after guard
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_graceful_shutdown_dev_id_without_credentials(monkeypatch, tmp_path):
|
|
170
|
+
mod = load_watcher_module()
|
|
171
|
+
"""_graceful_shutdown skips lock release when credentials are missing."""
|
|
172
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
173
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", None) # missing
|
|
174
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
175
|
+
pid_file = tmp_path / "watcher.pid"
|
|
176
|
+
pid_file.write_text("12345")
|
|
177
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
178
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
179
|
+
|
|
180
|
+
mod._graceful_shutdown() # should not attempt API call
|
|
181
|
+
assert not pid_file.exists()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_graceful_shutdown_queries_supabase_when_local_empty(monkeypatch, tmp_path):
|
|
185
|
+
mod = load_watcher_module()
|
|
186
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
187
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "http://test")
|
|
188
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "key")
|
|
189
|
+
monkeypatch.setattr(mod, "_shutdown_done", False)
|
|
190
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
191
|
+
|
|
192
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "pid.txt"))
|
|
193
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
|
|
194
|
+
|
|
195
|
+
mod._local_owned_locks.clear()
|
|
196
|
+
|
|
197
|
+
deleted_paths = []
|
|
198
|
+
|
|
199
|
+
class FakeSelectResp:
|
|
200
|
+
data = [{"file_path": "src/clean.py"}, {"file_path": "src/dirty.py"}]
|
|
201
|
+
|
|
202
|
+
class FakeTable:
|
|
203
|
+
def select(self, *args):
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def eq(self, *args):
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
def execute(self):
|
|
210
|
+
return FakeSelectResp()
|
|
211
|
+
|
|
212
|
+
def delete(self):
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
# For delete eq chaining
|
|
216
|
+
class DeleteFakeTable:
|
|
217
|
+
def __init__(self):
|
|
218
|
+
self.p = None
|
|
219
|
+
|
|
220
|
+
def delete(self):
|
|
221
|
+
return self
|
|
222
|
+
|
|
223
|
+
def eq(self, field, value):
|
|
224
|
+
if field == "file_path":
|
|
225
|
+
self.p = value
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def execute(self):
|
|
229
|
+
if self.p:
|
|
230
|
+
deleted_paths.append(self.p)
|
|
231
|
+
|
|
232
|
+
class FakeClient:
|
|
233
|
+
def table(self, name):
|
|
234
|
+
if not getattr(self, "selected", False):
|
|
235
|
+
self.selected = True
|
|
236
|
+
return FakeTable()
|
|
237
|
+
return DeleteFakeTable()
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
|
|
240
|
+
|
|
241
|
+
mod._graceful_shutdown()
|
|
242
|
+
|
|
243
|
+
assert "src/clean.py" in deleted_paths
|
|
244
|
+
assert "src/dirty.py" not in deleted_paths
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_graceful_shutdown_keeps_dirty_locks(monkeypatch, tmp_path):
|
|
248
|
+
mod = load_watcher_module()
|
|
249
|
+
"""§8a: Dirty files are NOT released during shutdown.
|
|
250
|
+
|
|
251
|
+
When _graceful_shutdown runs and git status shows files still dirty, those files'
|
|
252
|
+
locks must be preserved in Supabase (not deleted).
|
|
253
|
+
"""
|
|
254
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
255
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
256
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
257
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
258
|
+
|
|
259
|
+
pid_file = tmp_path / "watcher.pid"
|
|
260
|
+
pid_file.write_text("12345")
|
|
261
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
262
|
+
|
|
263
|
+
# Simulate: src/dirty.py is still dirty, src/clean.py is clean
|
|
264
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
|
|
265
|
+
# Pre-populate _local_owned_locks with both files
|
|
266
|
+
mod._local_owned_locks.clear()
|
|
267
|
+
mod._local_owned_locks.update({"src/dirty.py", "src/clean.py"})
|
|
268
|
+
|
|
269
|
+
deleted_files = []
|
|
270
|
+
|
|
271
|
+
class FakeTable:
|
|
272
|
+
def __init__(self):
|
|
273
|
+
self._file_path = None
|
|
274
|
+
self._is_delete = False
|
|
275
|
+
|
|
276
|
+
def delete(self):
|
|
277
|
+
self._is_delete = True
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
def select(self, *args):
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
def eq(self, field, value):
|
|
284
|
+
if field == "file_path" and self._is_delete:
|
|
285
|
+
self._file_path = value
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def execute(self):
|
|
289
|
+
if self._file_path and self._is_delete:
|
|
290
|
+
deleted_files.append(self._file_path)
|
|
291
|
+
return type("R", (), {"data": []})()
|
|
292
|
+
|
|
293
|
+
class FakeSupaClient:
|
|
294
|
+
def table(self, name):
|
|
295
|
+
return FakeTable()
|
|
296
|
+
|
|
297
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
|
|
298
|
+
|
|
299
|
+
mod._graceful_shutdown()
|
|
300
|
+
|
|
301
|
+
# src/clean.py should have been released; src/dirty.py should NOT
|
|
302
|
+
assert "src/clean.py" in deleted_files
|
|
303
|
+
assert "src/dirty.py" not in deleted_files
|
|
304
|
+
assert not pid_file.exists()
|
|
305
|
+
|
|
306
|
+
# Clean up
|
|
307
|
+
mod._local_owned_locks.clear()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_graceful_shutdown_local_empty_query_exception(monkeypatch, tmp_path):
|
|
311
|
+
"""When local lock set is empty and query fails, shutdown continues safely."""
|
|
312
|
+
mod = load_watcher_module()
|
|
313
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
314
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
315
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
316
|
+
monkeypatch.setattr(mod, "_shutdown_done", False)
|
|
317
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
318
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "watcher.pid"))
|
|
319
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
|
|
320
|
+
|
|
321
|
+
mod._local_owned_locks.clear()
|
|
322
|
+
|
|
323
|
+
class _BrokenTable:
|
|
324
|
+
def select(self, *args):
|
|
325
|
+
return self
|
|
326
|
+
|
|
327
|
+
def eq(self, *args):
|
|
328
|
+
return self
|
|
329
|
+
|
|
330
|
+
def execute(self):
|
|
331
|
+
raise RuntimeError("query fail")
|
|
332
|
+
|
|
333
|
+
class _Client:
|
|
334
|
+
def table(self, _name):
|
|
335
|
+
return _BrokenTable()
|
|
336
|
+
|
|
337
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: _Client())
|
|
338
|
+
mod._graceful_shutdown() # should not raise
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_graceful_shutdown_git_failure_releases_all(monkeypatch, tmp_path):
|
|
342
|
+
mod = load_watcher_module()
|
|
343
|
+
"""§8b: Git failure during shutdown falls back to blanket release-all.
|
|
344
|
+
|
|
345
|
+
When _run_git_status_porcelain raises an exception, _graceful_shutdown should fall
|
|
346
|
+
back to the legacy blanket delete behavior.
|
|
347
|
+
"""
|
|
348
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
349
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
350
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
351
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
352
|
+
|
|
353
|
+
pid_file = tmp_path / "watcher.pid"
|
|
354
|
+
pid_file.write_text("12345")
|
|
355
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
356
|
+
|
|
357
|
+
# Make git status fail
|
|
358
|
+
def failing_git_status():
|
|
359
|
+
raise RuntimeError("git not available")
|
|
360
|
+
|
|
361
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", failing_git_status)
|
|
362
|
+
|
|
363
|
+
blanket_deleted = []
|
|
364
|
+
|
|
365
|
+
class FakeTable:
|
|
366
|
+
def __init__(self):
|
|
367
|
+
self._eq_args = []
|
|
368
|
+
|
|
369
|
+
def delete(self):
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def eq(self, field, value):
|
|
373
|
+
self._eq_args.append((field, value))
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
def execute(self):
|
|
377
|
+
# Track that blanket delete was called (developer_id only)
|
|
378
|
+
dev_eq = [a for a in self._eq_args if a[0] == "developer_id"]
|
|
379
|
+
if dev_eq:
|
|
380
|
+
blanket_deleted.append(dev_eq[0][1])
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
class FakeSupaClient:
|
|
384
|
+
def table(self, name):
|
|
385
|
+
return FakeTable()
|
|
386
|
+
|
|
387
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeSupaClient())
|
|
388
|
+
|
|
389
|
+
mod._graceful_shutdown()
|
|
390
|
+
|
|
391
|
+
# Blanket release should have been called for test_dev
|
|
392
|
+
assert "test_dev" in blanket_deleted
|
|
393
|
+
assert not pid_file.exists()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_graceful_shutdown_release_exception_and_db_query_exception(
|
|
397
|
+
monkeypatch, tmp_path
|
|
398
|
+
):
|
|
399
|
+
"""Cover per-file release exception and fallback DB-query exception branches."""
|
|
400
|
+
mod = load_watcher_module()
|
|
401
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "test_dev")
|
|
402
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
403
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
404
|
+
monkeypatch.setattr(mod, "_shutdown_done", False)
|
|
405
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
406
|
+
|
|
407
|
+
pid_file = tmp_path / "watcher.pid"
|
|
408
|
+
pid_file.write_text("12345")
|
|
409
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
410
|
+
|
|
411
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: set())
|
|
412
|
+
mod._local_owned_locks.clear()
|
|
413
|
+
mod._local_owned_locks.add("src/will_fail.py")
|
|
414
|
+
|
|
415
|
+
class FakeClient:
|
|
416
|
+
def __init__(self):
|
|
417
|
+
self._mode = None
|
|
418
|
+
|
|
419
|
+
def table(self, name):
|
|
420
|
+
return self
|
|
421
|
+
|
|
422
|
+
def delete(self):
|
|
423
|
+
self._mode = "delete"
|
|
424
|
+
return self
|
|
425
|
+
|
|
426
|
+
def select(self, *args):
|
|
427
|
+
self._mode = "select"
|
|
428
|
+
return self
|
|
429
|
+
|
|
430
|
+
def eq(self, *args):
|
|
431
|
+
return self
|
|
432
|
+
|
|
433
|
+
def execute(self):
|
|
434
|
+
raise RuntimeError("backend down")
|
|
435
|
+
|
|
436
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: FakeClient())
|
|
437
|
+
mod._graceful_shutdown()
|
|
438
|
+
assert not pid_file.exists()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_graceful_shutdown_pid_remove_retries_then_warns(monkeypatch, tmp_path):
|
|
442
|
+
"""PID removal should retry and warn after three OSError attempts."""
|
|
443
|
+
mod = load_watcher_module()
|
|
444
|
+
monkeypatch.setattr(mod, "_shutdown_done", False)
|
|
445
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
446
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", None)
|
|
447
|
+
|
|
448
|
+
pid_file = tmp_path / "blocked.pid"
|
|
449
|
+
pid_file.write_text("12345")
|
|
450
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
451
|
+
|
|
452
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda p: True)
|
|
453
|
+
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
|
|
454
|
+
monkeypatch.setattr(
|
|
455
|
+
mod.os, "remove", lambda p: (_ for _ in ()).throw(OSError("locked"))
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# no raise expected after retry loop
|
|
459
|
+
mod._graceful_shutdown()
|