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,1925 @@
|
|
|
1
|
+
"""Main/integration-focused tests for live_locks_watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from ._helpers import load_watcher_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _setup_common(monkeypatch, mod):
|
|
17
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
18
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
19
|
+
monkeypatch.setattr(mod, "SUPABASE_URL", "https://test.supabase.co")
|
|
20
|
+
monkeypatch.setattr(mod, "SUPABASE_ANON_KEY", "test_key")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _stub_supabase(monkeypatch, mod):
|
|
24
|
+
fake_client = mock.MagicMock()
|
|
25
|
+
table_select = fake_client.table.return_value.select.return_value
|
|
26
|
+
table_select.eq.return_value.execute.return_value = mock.MagicMock(data=[])
|
|
27
|
+
fake_client.table.return_value.select.return_value.execute.return_value = (
|
|
28
|
+
mock.MagicMock(data=[])
|
|
29
|
+
)
|
|
30
|
+
monkeypatch.setattr(mod, "create_client", lambda url, key: fake_client)
|
|
31
|
+
return fake_client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _stub_loop_then_interrupt(monkeypatch, mod, max_ticks=2):
|
|
35
|
+
ticks = [0]
|
|
36
|
+
|
|
37
|
+
def mock_sleep(x):
|
|
38
|
+
ticks[0] += 1
|
|
39
|
+
if ticks[0] >= max_ticks:
|
|
40
|
+
raise KeyboardInterrupt()
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(mod.time, "sleep", mock_sleep)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_main_with_default_args(monkeypatch):
|
|
46
|
+
mod = load_watcher_module()
|
|
47
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
48
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
49
|
+
monkeypatch.setenv("DEVELOPER_ID", "test_dev")
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
52
|
+
|
|
53
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
54
|
+
return b""
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
57
|
+
|
|
58
|
+
sleep_count = [0]
|
|
59
|
+
|
|
60
|
+
def mock_sleep(seconds):
|
|
61
|
+
sleep_count[0] += 1
|
|
62
|
+
if sleep_count[0] > 2:
|
|
63
|
+
raise KeyboardInterrupt()
|
|
64
|
+
|
|
65
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
mod.main()
|
|
69
|
+
except (KeyboardInterrupt, SystemExit):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_main_with_interval_arg(monkeypatch):
|
|
74
|
+
mod = load_watcher_module()
|
|
75
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
76
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--interval", "10"])
|
|
79
|
+
|
|
80
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
81
|
+
return b""
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
84
|
+
|
|
85
|
+
sleep_count = [0]
|
|
86
|
+
|
|
87
|
+
def mock_sleep(seconds):
|
|
88
|
+
sleep_count[0] += 1
|
|
89
|
+
if sleep_count[0] > 1:
|
|
90
|
+
raise KeyboardInterrupt()
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
mod.main()
|
|
96
|
+
except (KeyboardInterrupt, SystemExit):
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_main_with_timeout_arg(monkeypatch):
|
|
101
|
+
mod = load_watcher_module()
|
|
102
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
103
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "30"])
|
|
106
|
+
|
|
107
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
108
|
+
return b""
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
111
|
+
|
|
112
|
+
sleep_count = [0]
|
|
113
|
+
|
|
114
|
+
def mock_sleep(seconds):
|
|
115
|
+
sleep_count[0] += 1
|
|
116
|
+
if sleep_count[0] > 1:
|
|
117
|
+
raise KeyboardInterrupt()
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
mod.main()
|
|
123
|
+
except (KeyboardInterrupt, SystemExit):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_main_detects_file_changes(monkeypatch):
|
|
128
|
+
mod = load_watcher_module()
|
|
129
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
130
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
131
|
+
monkeypatch.setenv("DEVELOPER_ID", "test_dev")
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
134
|
+
|
|
135
|
+
call_count = [0]
|
|
136
|
+
|
|
137
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
138
|
+
call_count[0] += 1
|
|
139
|
+
if "status" in cmd:
|
|
140
|
+
if call_count[0] <= 2:
|
|
141
|
+
return b""
|
|
142
|
+
return b"M src/app.py\n"
|
|
143
|
+
return b"test_dev\n"
|
|
144
|
+
|
|
145
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
146
|
+
|
|
147
|
+
sleep_count = [0]
|
|
148
|
+
|
|
149
|
+
def mock_sleep(seconds):
|
|
150
|
+
sleep_count[0] += 1
|
|
151
|
+
if sleep_count[0] > 2:
|
|
152
|
+
raise KeyboardInterrupt()
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
mod.main()
|
|
158
|
+
except (KeyboardInterrupt, SystemExit, AttributeError, NameError):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_main_releases_locks_on_file_removal(monkeypatch):
|
|
163
|
+
mod = load_watcher_module()
|
|
164
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
165
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
166
|
+
monkeypatch.setenv("DEVELOPER_ID", "test_dev")
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
169
|
+
|
|
170
|
+
call_count = [0]
|
|
171
|
+
|
|
172
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
173
|
+
call_count[0] += 1
|
|
174
|
+
if "status" in cmd:
|
|
175
|
+
if call_count[0] <= 2:
|
|
176
|
+
return b"M src/app.py\n"
|
|
177
|
+
return b""
|
|
178
|
+
return b"test_dev\n"
|
|
179
|
+
|
|
180
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
181
|
+
|
|
182
|
+
sleep_count = [0]
|
|
183
|
+
|
|
184
|
+
def mock_sleep(seconds):
|
|
185
|
+
sleep_count[0] += 1
|
|
186
|
+
if sleep_count[0] > 2:
|
|
187
|
+
raise KeyboardInterrupt()
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
mod.main()
|
|
193
|
+
except (KeyboardInterrupt, SystemExit):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_main_handles_keyboard_interrupt(monkeypatch):
|
|
198
|
+
mod = load_watcher_module()
|
|
199
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
200
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
203
|
+
|
|
204
|
+
call_count = [0]
|
|
205
|
+
|
|
206
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
207
|
+
call_count[0] += 1
|
|
208
|
+
if "user.name" in cmd:
|
|
209
|
+
return b"test_user\n"
|
|
210
|
+
if "branch" in cmd:
|
|
211
|
+
return b"main\n"
|
|
212
|
+
raise KeyboardInterrupt()
|
|
213
|
+
|
|
214
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
mod.main()
|
|
218
|
+
except (SystemExit, KeyboardInterrupt):
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_main_handles_git_error(monkeypatch):
|
|
223
|
+
mod = load_watcher_module()
|
|
224
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
225
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
228
|
+
|
|
229
|
+
call_count = [0]
|
|
230
|
+
|
|
231
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
232
|
+
call_count[0] += 1
|
|
233
|
+
if "user.name" in cmd:
|
|
234
|
+
return b"test_user\n"
|
|
235
|
+
if "branch" in cmd:
|
|
236
|
+
return b"main\n"
|
|
237
|
+
raise subprocess.CalledProcessError(1, cmd)
|
|
238
|
+
|
|
239
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
240
|
+
|
|
241
|
+
sleep_count = [0]
|
|
242
|
+
|
|
243
|
+
def mock_sleep(seconds):
|
|
244
|
+
sleep_count[0] += 1
|
|
245
|
+
if sleep_count[0] > 1:
|
|
246
|
+
raise KeyboardInterrupt()
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
mod.main()
|
|
252
|
+
except (SystemExit, KeyboardInterrupt):
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_main_timeout_triggers(monkeypatch):
|
|
257
|
+
mod = load_watcher_module()
|
|
258
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
259
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
260
|
+
|
|
261
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
|
|
262
|
+
|
|
263
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
264
|
+
if "user.name" in cmd:
|
|
265
|
+
return b"test_user\n"
|
|
266
|
+
if "branch" in cmd:
|
|
267
|
+
return b"main\n"
|
|
268
|
+
return b""
|
|
269
|
+
|
|
270
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
271
|
+
|
|
272
|
+
# Use a mock for time.sleep that also advances "now"
|
|
273
|
+
|
|
274
|
+
real_now = datetime.now
|
|
275
|
+
offset = [timedelta()]
|
|
276
|
+
|
|
277
|
+
def advancing_sleep(seconds):
|
|
278
|
+
offset[0] += timedelta(minutes=2)
|
|
279
|
+
|
|
280
|
+
def fake_now(*args, **kwargs):
|
|
281
|
+
return real_now() + offset[0]
|
|
282
|
+
|
|
283
|
+
monkeypatch.setattr("time.sleep", advancing_sleep)
|
|
284
|
+
monkeypatch.setattr(
|
|
285
|
+
mod,
|
|
286
|
+
"datetime",
|
|
287
|
+
type(
|
|
288
|
+
"FakeDT",
|
|
289
|
+
(),
|
|
290
|
+
{
|
|
291
|
+
"now": staticmethod(fake_now),
|
|
292
|
+
"fromisoformat": datetime.fromisoformat,
|
|
293
|
+
},
|
|
294
|
+
)(),
|
|
295
|
+
raising=False,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
mod.main()
|
|
300
|
+
except (SystemExit, AttributeError, TypeError):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---- Auto-migrated from migrated_remaining ----
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_main_missing_supabase_url(monkeypatch):
|
|
308
|
+
"""Test main exits when SUPABASE_URL is missing."""
|
|
309
|
+
mod = load_watcher_module()
|
|
310
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", None)
|
|
311
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
312
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
313
|
+
|
|
314
|
+
with pytest.raises(SystemExit):
|
|
315
|
+
mod.main()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_main_missing_supabase_key(monkeypatch):
|
|
319
|
+
"""Test main exits when SUPABASE_ANON_KEY is missing."""
|
|
320
|
+
mod = load_watcher_module()
|
|
321
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
322
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", None)
|
|
323
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
324
|
+
|
|
325
|
+
with pytest.raises(SystemExit):
|
|
326
|
+
mod.main()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ============================================================================
|
|
330
|
+
# main() PID File Tests (lines 228-229)
|
|
331
|
+
# ============================================================================
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_main_writes_pid_file(monkeypatch, tmp_path):
|
|
335
|
+
"""Test that main() writes a PID file on startup."""
|
|
336
|
+
mod = load_watcher_module()
|
|
337
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
338
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
339
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
340
|
+
|
|
341
|
+
pid_file = tmp_path / "mod.pid"
|
|
342
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
343
|
+
|
|
344
|
+
class FakeSupaClient:
|
|
345
|
+
def table(self, name):
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def delete(self):
|
|
349
|
+
return self
|
|
350
|
+
|
|
351
|
+
def eq(self, *args):
|
|
352
|
+
return self
|
|
353
|
+
|
|
354
|
+
def execute(self):
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
def rpc(self, *args, **kwargs):
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
361
|
+
|
|
362
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
363
|
+
if "user.name" in cmd:
|
|
364
|
+
return b"test_user\n"
|
|
365
|
+
if "branch" in cmd:
|
|
366
|
+
return b"main\n"
|
|
367
|
+
return b""
|
|
368
|
+
|
|
369
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
370
|
+
|
|
371
|
+
sleep_count = [0]
|
|
372
|
+
|
|
373
|
+
def mock_sleep(seconds):
|
|
374
|
+
sleep_count[0] += 1
|
|
375
|
+
if sleep_count[0] > 1:
|
|
376
|
+
raise KeyboardInterrupt()
|
|
377
|
+
|
|
378
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
mod.main()
|
|
382
|
+
except (KeyboardInterrupt, SystemExit):
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
# PID file should have been written (might be cleaned on shutdown)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ============================================================================
|
|
389
|
+
# main() Conflict Detection Tests (lines 333-335, 343)
|
|
390
|
+
# ============================================================================
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_main_detects_conflict(monkeypatch, tmp_path):
|
|
394
|
+
"""Test that main detects lock conflicts and notifies."""
|
|
395
|
+
mod = load_watcher_module()
|
|
396
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
397
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
398
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
399
|
+
|
|
400
|
+
pid_file = tmp_path / "mod.pid"
|
|
401
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
402
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
403
|
+
|
|
404
|
+
class FakeRPCResult:
|
|
405
|
+
data = [{"status": "conflict", "owner": "other_dev"}]
|
|
406
|
+
|
|
407
|
+
class FakeSupaClient:
|
|
408
|
+
def table(self, name):
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def delete(self):
|
|
412
|
+
return self
|
|
413
|
+
|
|
414
|
+
def eq(self, *args):
|
|
415
|
+
return self
|
|
416
|
+
|
|
417
|
+
def execute(self):
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
def rpc(self, name, params):
|
|
421
|
+
return self
|
|
422
|
+
|
|
423
|
+
# The rpc() returns self, then .execute() returns FakeRPCResult
|
|
424
|
+
class _rpc_chain:
|
|
425
|
+
@staticmethod
|
|
426
|
+
def execute():
|
|
427
|
+
return FakeRPCResult()
|
|
428
|
+
|
|
429
|
+
# Need a more careful mock
|
|
430
|
+
class RPCChain:
|
|
431
|
+
def execute(self):
|
|
432
|
+
return FakeRPCResult()
|
|
433
|
+
|
|
434
|
+
class ConflictSupaClient:
|
|
435
|
+
def table(self, name):
|
|
436
|
+
return self
|
|
437
|
+
|
|
438
|
+
def delete(self):
|
|
439
|
+
return self
|
|
440
|
+
|
|
441
|
+
def eq(self, *args):
|
|
442
|
+
return self
|
|
443
|
+
|
|
444
|
+
def execute(self):
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
def rpc(self, name, params):
|
|
448
|
+
return RPCChain()
|
|
449
|
+
|
|
450
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: ConflictSupaClient())
|
|
451
|
+
|
|
452
|
+
git_call_count = [0]
|
|
453
|
+
|
|
454
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
455
|
+
git_call_count[0] += 1
|
|
456
|
+
if "user.name" in cmd:
|
|
457
|
+
return b"test_user\n"
|
|
458
|
+
if "branch" in cmd:
|
|
459
|
+
return b"main\n"
|
|
460
|
+
if "status" in cmd:
|
|
461
|
+
# First call: empty, second call: file changed
|
|
462
|
+
if git_call_count[0] <= 3:
|
|
463
|
+
return b""
|
|
464
|
+
return b" M src/app.py\n"
|
|
465
|
+
return b""
|
|
466
|
+
|
|
467
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
468
|
+
|
|
469
|
+
sleep_count = [0]
|
|
470
|
+
|
|
471
|
+
def mock_sleep(seconds):
|
|
472
|
+
sleep_count[0] += 1
|
|
473
|
+
if sleep_count[0] > 3:
|
|
474
|
+
raise KeyboardInterrupt()
|
|
475
|
+
|
|
476
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
mod.main()
|
|
480
|
+
except (KeyboardInterrupt, SystemExit):
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ============================================================================
|
|
485
|
+
# main() Lock Release Tests (lines 350-351, 358-359, 369-370)
|
|
486
|
+
# ============================================================================
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def test_main_releases_lock_on_revert(monkeypatch, tmp_path):
|
|
490
|
+
"""Test that locks are released when files revert to clean state."""
|
|
491
|
+
mod = load_watcher_module()
|
|
492
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
493
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
494
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
495
|
+
|
|
496
|
+
pid_file = tmp_path / "mod.pid"
|
|
497
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
498
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
499
|
+
|
|
500
|
+
class FakeOKResult:
|
|
501
|
+
data = [{"status": "ok"}]
|
|
502
|
+
|
|
503
|
+
class ReleaseSupaClient:
|
|
504
|
+
def table(self, name):
|
|
505
|
+
return self
|
|
506
|
+
|
|
507
|
+
def delete(self):
|
|
508
|
+
return self
|
|
509
|
+
|
|
510
|
+
def eq(self, *args):
|
|
511
|
+
return self
|
|
512
|
+
|
|
513
|
+
def execute(self):
|
|
514
|
+
return None
|
|
515
|
+
|
|
516
|
+
def rpc(self, name, params):
|
|
517
|
+
return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
|
|
518
|
+
|
|
519
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: ReleaseSupaClient())
|
|
520
|
+
|
|
521
|
+
git_call_count = [0]
|
|
522
|
+
|
|
523
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
524
|
+
git_call_count[0] += 1
|
|
525
|
+
if "user.name" in cmd:
|
|
526
|
+
return b"test_user\n"
|
|
527
|
+
if "branch" in cmd:
|
|
528
|
+
return b"main\n"
|
|
529
|
+
if "status" in cmd:
|
|
530
|
+
# First status: file modified, second: clean
|
|
531
|
+
if git_call_count[0] <= 4:
|
|
532
|
+
return b" M src/app.py\n"
|
|
533
|
+
return b""
|
|
534
|
+
return b""
|
|
535
|
+
|
|
536
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
537
|
+
|
|
538
|
+
sleep_count = [0]
|
|
539
|
+
|
|
540
|
+
def mock_sleep(seconds):
|
|
541
|
+
sleep_count[0] += 1
|
|
542
|
+
if sleep_count[0] > 3:
|
|
543
|
+
raise KeyboardInterrupt()
|
|
544
|
+
|
|
545
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
mod.main()
|
|
549
|
+
except (KeyboardInterrupt, SystemExit):
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_main_release_lock_exception(monkeypatch, tmp_path):
|
|
554
|
+
"""Test that lock release errors are handled (lines 369-370)."""
|
|
555
|
+
mod = load_watcher_module()
|
|
556
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
557
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
558
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
559
|
+
|
|
560
|
+
pid_file = tmp_path / "mod.pid"
|
|
561
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
562
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
563
|
+
|
|
564
|
+
class FakeOKResult:
|
|
565
|
+
data = [{"status": "ok"}]
|
|
566
|
+
|
|
567
|
+
class ErrorOnDeleteClient:
|
|
568
|
+
def table(self, name):
|
|
569
|
+
return self
|
|
570
|
+
|
|
571
|
+
def delete(self):
|
|
572
|
+
raise RuntimeError("Delete failed")
|
|
573
|
+
|
|
574
|
+
def eq(self, *args):
|
|
575
|
+
return self
|
|
576
|
+
|
|
577
|
+
def execute(self):
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
def rpc(self, name, params):
|
|
581
|
+
return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
|
|
582
|
+
|
|
583
|
+
monkeypatch.setattr(
|
|
584
|
+
watcher, "create_client", lambda url, key: ErrorOnDeleteClient()
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
git_call_count = [0]
|
|
588
|
+
|
|
589
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
590
|
+
git_call_count[0] += 1
|
|
591
|
+
if "user.name" in cmd:
|
|
592
|
+
return b"test_user\n"
|
|
593
|
+
if "branch" in cmd:
|
|
594
|
+
return b"main\n"
|
|
595
|
+
if "status" in cmd:
|
|
596
|
+
if git_call_count[0] <= 4:
|
|
597
|
+
return b" M src/app.py\n"
|
|
598
|
+
return b""
|
|
599
|
+
return b""
|
|
600
|
+
|
|
601
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
602
|
+
|
|
603
|
+
sleep_count = [0]
|
|
604
|
+
|
|
605
|
+
def mock_sleep(seconds):
|
|
606
|
+
sleep_count[0] += 1
|
|
607
|
+
if sleep_count[0] > 3:
|
|
608
|
+
raise KeyboardInterrupt()
|
|
609
|
+
|
|
610
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
611
|
+
|
|
612
|
+
try:
|
|
613
|
+
mod.main()
|
|
614
|
+
except (KeyboardInterrupt, SystemExit):
|
|
615
|
+
pass
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ============================================================================
|
|
619
|
+
# main() Idle Timeout Tests (lines 381-382)
|
|
620
|
+
# ============================================================================
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_main_idle_timeout(monkeypatch, tmp_path):
|
|
624
|
+
"""Test that main exits after idle timeout with no changes."""
|
|
625
|
+
mod = load_watcher_module()
|
|
626
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
627
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
628
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py", "--timeout", "1"])
|
|
629
|
+
|
|
630
|
+
pid_file = tmp_path / "mod.pid"
|
|
631
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
632
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
633
|
+
|
|
634
|
+
class FakeSupaClient:
|
|
635
|
+
def table(self, name):
|
|
636
|
+
return self
|
|
637
|
+
|
|
638
|
+
def delete(self):
|
|
639
|
+
return self
|
|
640
|
+
|
|
641
|
+
def eq(self, *args):
|
|
642
|
+
return self
|
|
643
|
+
|
|
644
|
+
def execute(self):
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
648
|
+
|
|
649
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
650
|
+
if "user.name" in cmd:
|
|
651
|
+
return b"test_user\n"
|
|
652
|
+
if "branch" in cmd:
|
|
653
|
+
return b"main\n"
|
|
654
|
+
return b""
|
|
655
|
+
|
|
656
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
657
|
+
|
|
658
|
+
real_now = datetime.now
|
|
659
|
+
offset = [timedelta()]
|
|
660
|
+
|
|
661
|
+
def advancing_sleep(seconds):
|
|
662
|
+
offset[0] += timedelta(minutes=2)
|
|
663
|
+
|
|
664
|
+
def fake_now(*args, **kwargs):
|
|
665
|
+
return real_now() + offset[0]
|
|
666
|
+
|
|
667
|
+
monkeypatch.setattr("time.sleep", advancing_sleep)
|
|
668
|
+
monkeypatch.setattr(
|
|
669
|
+
watcher,
|
|
670
|
+
"datetime",
|
|
671
|
+
type(
|
|
672
|
+
"FakeDT",
|
|
673
|
+
(),
|
|
674
|
+
{
|
|
675
|
+
"now": staticmethod(fake_now),
|
|
676
|
+
"fromisoformat": datetime.fromisoformat,
|
|
677
|
+
},
|
|
678
|
+
)(),
|
|
679
|
+
raising=False,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
mod.main()
|
|
684
|
+
except (SystemExit, AttributeError, TypeError):
|
|
685
|
+
pass
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ============================================================================
|
|
689
|
+
# main() Parent Process Check Tests
|
|
690
|
+
# ============================================================================
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_main_does_not_exit_on_parent_death(monkeypatch, tmp_path):
|
|
694
|
+
"""Test that main does NOT exit when parent process dies.
|
|
695
|
+
|
|
696
|
+
The parent PID check was removed — PyCharm manages the process lifecycle via its
|
|
697
|
+
stop button / IDE close. The watcher should keep running until explicitly stopped
|
|
698
|
+
(KeyboardInterrupt / signal).
|
|
699
|
+
"""
|
|
700
|
+
mod = load_watcher_module()
|
|
701
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
702
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
703
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
704
|
+
|
|
705
|
+
pid_file = tmp_path / "mod.pid"
|
|
706
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
707
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
708
|
+
|
|
709
|
+
class FakeSupaClient:
|
|
710
|
+
def table(self, name):
|
|
711
|
+
return self
|
|
712
|
+
|
|
713
|
+
def delete(self):
|
|
714
|
+
return self
|
|
715
|
+
|
|
716
|
+
def eq(self, *args):
|
|
717
|
+
return self
|
|
718
|
+
|
|
719
|
+
def execute(self):
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
723
|
+
|
|
724
|
+
# Even with _is_process_alive returning False, the watcher should
|
|
725
|
+
# keep running because it no longer checks the parent PID.
|
|
726
|
+
monkeypatch.setattr(watcher, "_is_process_alive", lambda pid: False)
|
|
727
|
+
|
|
728
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
729
|
+
if "user.name" in cmd:
|
|
730
|
+
return b"test_user\n"
|
|
731
|
+
if "branch" in cmd:
|
|
732
|
+
return b"main\n"
|
|
733
|
+
return b""
|
|
734
|
+
|
|
735
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
736
|
+
|
|
737
|
+
sleep_count = [0]
|
|
738
|
+
|
|
739
|
+
def mock_sleep(seconds):
|
|
740
|
+
sleep_count[0] += 1
|
|
741
|
+
if sleep_count[0] > 3:
|
|
742
|
+
raise KeyboardInterrupt()
|
|
743
|
+
|
|
744
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
mod.main()
|
|
748
|
+
except (SystemExit, KeyboardInterrupt):
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
# The watcher ran for multiple iterations (did not exit early)
|
|
752
|
+
assert sleep_count[0] > 2
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# ============================================================================
|
|
756
|
+
# __main__ Block Test (line 393)
|
|
757
|
+
# ============================================================================
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def test_main_conflict_cleared_on_revert(monkeypatch, tmp_path):
|
|
761
|
+
"""Test that conflicts are cleared when file reverts."""
|
|
762
|
+
mod = load_watcher_module()
|
|
763
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
764
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
765
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
766
|
+
|
|
767
|
+
pid_file = tmp_path / "mod.pid"
|
|
768
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
769
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
770
|
+
|
|
771
|
+
# Pre-populate _active_conflicts
|
|
772
|
+
mod._active_conflicts.clear()
|
|
773
|
+
mod._active_conflicts.add("src/conflict_file.py")
|
|
774
|
+
|
|
775
|
+
class FakeOKResult:
|
|
776
|
+
data = [{"status": "ok"}]
|
|
777
|
+
|
|
778
|
+
class FakeSupaClient:
|
|
779
|
+
def table(self, name):
|
|
780
|
+
return self
|
|
781
|
+
|
|
782
|
+
def delete(self):
|
|
783
|
+
return self
|
|
784
|
+
|
|
785
|
+
def eq(self, *args):
|
|
786
|
+
return self
|
|
787
|
+
|
|
788
|
+
def execute(self):
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
def rpc(self, name, params):
|
|
792
|
+
return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
|
|
793
|
+
|
|
794
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
795
|
+
|
|
796
|
+
git_call_count = [0]
|
|
797
|
+
|
|
798
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
799
|
+
git_call_count[0] += 1
|
|
800
|
+
if "user.name" in cmd:
|
|
801
|
+
return b"test_user\n"
|
|
802
|
+
if "branch" in cmd:
|
|
803
|
+
return b"main\n"
|
|
804
|
+
if "status" in cmd:
|
|
805
|
+
# First: file present (matches _active_conflicts), then: empty
|
|
806
|
+
if git_call_count[0] <= 4:
|
|
807
|
+
return b" M src/conflict_file.py\n"
|
|
808
|
+
return b""
|
|
809
|
+
return b""
|
|
810
|
+
|
|
811
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
812
|
+
|
|
813
|
+
sleep_count = [0]
|
|
814
|
+
|
|
815
|
+
def mock_sleep(seconds):
|
|
816
|
+
sleep_count[0] += 1
|
|
817
|
+
if sleep_count[0] > 3:
|
|
818
|
+
raise KeyboardInterrupt()
|
|
819
|
+
|
|
820
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
mod.main()
|
|
824
|
+
except (KeyboardInterrupt, SystemExit):
|
|
825
|
+
pass
|
|
826
|
+
|
|
827
|
+
# Clean up
|
|
828
|
+
mod._active_conflicts.clear()
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# ---------------------------------------------------------------------------
|
|
832
|
+
# Consolidated tests moved from smaller modules:
|
|
833
|
+
# - test_live_watcher_more.py
|
|
834
|
+
# - test_live_watcher_more2.py
|
|
835
|
+
# - test_live_watcher_more3.py
|
|
836
|
+
# - test_live_locks_watcher_extra.py
|
|
837
|
+
# These were adapted to reuse the `watcher` module already loaded above.
|
|
838
|
+
# ---------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
# ---------------------------------------------------------------------------
|
|
842
|
+
# Consolidated tests moved from smaller modules:
|
|
843
|
+
# - test_live_watcher_more.py
|
|
844
|
+
# - test_live_watcher_more2.py
|
|
845
|
+
# - test_live_watcher_more3.py
|
|
846
|
+
# - test_live_locks_watcher_extra.py
|
|
847
|
+
# These were adapted to reuse the `watcher` module already loaded above.
|
|
848
|
+
# ---------------------------------------------------------------------------
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def test_main_handles_acquire_exception_and_exits(monkeypatch):
|
|
852
|
+
mod = load_watcher_module()
|
|
853
|
+
# Prepare environment for main() to run one loop and exit via KeyboardInterrupt
|
|
854
|
+
monkeypatch.setenv("SUPABASE_URL", "https://example.invalid")
|
|
855
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon:fake")
|
|
856
|
+
|
|
857
|
+
# Fake create_client that returns a client whose rpc().execute() raises
|
|
858
|
+
class ExplodeClient:
|
|
859
|
+
def rpc(self, *a, **k):
|
|
860
|
+
return self
|
|
861
|
+
|
|
862
|
+
def execute(self):
|
|
863
|
+
raise RuntimeError("rpc failed")
|
|
864
|
+
|
|
865
|
+
def table(self, *a, **k):
|
|
866
|
+
return self
|
|
867
|
+
|
|
868
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: ExplodeClient())
|
|
869
|
+
monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
|
|
870
|
+
|
|
871
|
+
# Make git status report one modified file so the watcher will attempt acquire
|
|
872
|
+
monkeypatch.setattr("subprocess.check_output", lambda *a, **k: b" M src/app.py")
|
|
873
|
+
|
|
874
|
+
# Make sleep raise KeyboardInterrupt to exit after first iteration
|
|
875
|
+
def raise_kb(_):
|
|
876
|
+
raise KeyboardInterrupt()
|
|
877
|
+
|
|
878
|
+
monkeypatch.setattr(mod.time, "sleep", raise_kb)
|
|
879
|
+
|
|
880
|
+
# Ensure developer id is deterministic
|
|
881
|
+
monkeypatch.setattr(watcher, "_get_developer_id", lambda: "me")
|
|
882
|
+
|
|
883
|
+
# Avoid argparse picking up pytest args
|
|
884
|
+
import sys as _sys
|
|
885
|
+
|
|
886
|
+
monkeypatch.setattr(_sys, "argv", ["collab"]) # safe minimal argv
|
|
887
|
+
|
|
888
|
+
# Should not raise (main handles KeyboardInterrupt and exits cleanly)
|
|
889
|
+
mod.main()
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def test_main_existing_watcher_guard_lock_daemon_label(monkeypatch, tmp_path):
|
|
893
|
+
"""Main exits early with normalized lock-daemon label when watcher already runs."""
|
|
894
|
+
mod = load_watcher_module()
|
|
895
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
896
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
897
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
898
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
899
|
+
|
|
900
|
+
monkeypatch.setattr(
|
|
901
|
+
watcher,
|
|
902
|
+
"_existing_watcher_running",
|
|
903
|
+
lambda: (True, 4321, "python something", "lock-daemon"),
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
info_messages = []
|
|
907
|
+
monkeypatch.setattr(
|
|
908
|
+
watcher.logger,
|
|
909
|
+
"info",
|
|
910
|
+
lambda msg, *a: info_messages.append(msg % a if a else msg),
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
with pytest.raises(SystemExit) as ex:
|
|
914
|
+
mod.main()
|
|
915
|
+
|
|
916
|
+
assert ex.value.code == 0
|
|
917
|
+
assert any("python lock_client.py" in m for m in info_messages)
|
|
918
|
+
assert any("daemon-status" in m for m in info_messages)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def test_main_existing_watcher_guard_pycharm_watcher_label(monkeypatch, tmp_path):
|
|
922
|
+
"""Main exits early with pycharm watcher label when entrypoint is pycharm-
|
|
923
|
+
watcher."""
|
|
924
|
+
mod = load_watcher_module()
|
|
925
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
926
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
927
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
928
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
929
|
+
|
|
930
|
+
monkeypatch.setattr(
|
|
931
|
+
watcher,
|
|
932
|
+
"_existing_watcher_running",
|
|
933
|
+
lambda: (True, 4567, "python whatever", "pycharm-watcher"),
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
info_messages = []
|
|
937
|
+
monkeypatch.setattr(
|
|
938
|
+
watcher.logger,
|
|
939
|
+
"info",
|
|
940
|
+
lambda msg, *a: info_messages.append(msg % a if a else msg),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
with pytest.raises(SystemExit) as ex:
|
|
944
|
+
mod.main()
|
|
945
|
+
|
|
946
|
+
assert ex.value.code == 0
|
|
947
|
+
assert any("python -m src.live_locks_watcher" in m for m in info_messages)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def test_main_existing_watcher_guard_uses_shortened_cmd_label(monkeypatch, tmp_path):
|
|
951
|
+
"""Main uses _shorten_process_label(existing_cmd) when entrypoint is absent."""
|
|
952
|
+
mod = load_watcher_module()
|
|
953
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
954
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
955
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
956
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
957
|
+
|
|
958
|
+
monkeypatch.setattr(
|
|
959
|
+
watcher,
|
|
960
|
+
"_existing_watcher_running",
|
|
961
|
+
lambda: (True, 1111, "python very/long/cmdline", None),
|
|
962
|
+
)
|
|
963
|
+
monkeypatch.setattr(watcher, "_shorten_process_label", lambda _: "short-label")
|
|
964
|
+
|
|
965
|
+
info_messages = []
|
|
966
|
+
monkeypatch.setattr(
|
|
967
|
+
watcher.logger,
|
|
968
|
+
"info",
|
|
969
|
+
lambda msg, *a: info_messages.append(msg % a if a else msg),
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
with pytest.raises(SystemExit) as ex:
|
|
973
|
+
mod.main()
|
|
974
|
+
|
|
975
|
+
assert ex.value.code == 0
|
|
976
|
+
assert any("short-label" in m for m in info_messages)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def test_main_existing_watcher_guard_ignored_for_pytest_pidfile(monkeypatch):
|
|
980
|
+
"""Existing watcher guard is bypassed when PID_FILE is test-local pytest path."""
|
|
981
|
+
mod = load_watcher_module()
|
|
982
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
983
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
984
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
985
|
+
monkeypatch.setattr(watcher, "PID_FILE", "C:/tmp/pytest_collab_watcher.pid")
|
|
986
|
+
|
|
987
|
+
# Simulate existing watcher, but bypass guard due to test-local PID file.
|
|
988
|
+
monkeypatch.setattr(
|
|
989
|
+
watcher,
|
|
990
|
+
"_existing_watcher_running",
|
|
991
|
+
lambda: (True, 9999, "python cmd", "lock-client"),
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Force a controlled exit later in startup to prove we passed the guard
|
|
995
|
+
monkeypatch.setattr(watcher, "create_client", None)
|
|
996
|
+
|
|
997
|
+
debug_messages = []
|
|
998
|
+
monkeypatch.setattr(
|
|
999
|
+
watcher.logger,
|
|
1000
|
+
"debug",
|
|
1001
|
+
lambda msg, *a: debug_messages.append(msg % a if a else msg),
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
with pytest.raises(SystemExit) as ex:
|
|
1005
|
+
mod.main()
|
|
1006
|
+
|
|
1007
|
+
# Exit from create_client missing path, not from existing-watcher guard
|
|
1008
|
+
assert ex.value.code == 1
|
|
1009
|
+
assert any("test-local PID file" in m for m in debug_messages)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
# ============================================================================
|
|
1013
|
+
# PID File OSError Tests (lines 192-193, 228-229)
|
|
1014
|
+
# ============================================================================
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def test_main_pid_write_oserror(monkeypatch, tmp_path):
|
|
1018
|
+
"""Test main handles OSError when writing PID file (lines 228-229)."""
|
|
1019
|
+
mod = load_watcher_module()
|
|
1020
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
1021
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
1022
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
1023
|
+
|
|
1024
|
+
# Set PID file to an unwritable location
|
|
1025
|
+
monkeypatch.setattr(
|
|
1026
|
+
watcher, "PID_FILE", str(tmp_path / "no" / "such" / "dir" / "pid")
|
|
1027
|
+
)
|
|
1028
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
1029
|
+
|
|
1030
|
+
class FakeSupaClient:
|
|
1031
|
+
def table(self, name):
|
|
1032
|
+
return self
|
|
1033
|
+
|
|
1034
|
+
def delete(self):
|
|
1035
|
+
return self
|
|
1036
|
+
|
|
1037
|
+
def eq(self, *args):
|
|
1038
|
+
return self
|
|
1039
|
+
|
|
1040
|
+
def execute(self):
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
1044
|
+
|
|
1045
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1046
|
+
if "user.name" in cmd:
|
|
1047
|
+
return b"test_user\n"
|
|
1048
|
+
if "branch" in cmd:
|
|
1049
|
+
return b"main\n"
|
|
1050
|
+
return b""
|
|
1051
|
+
|
|
1052
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1053
|
+
|
|
1054
|
+
sleep_count = [0]
|
|
1055
|
+
|
|
1056
|
+
def mock_sleep(seconds):
|
|
1057
|
+
sleep_count[0] += 1
|
|
1058
|
+
if sleep_count[0] > 1:
|
|
1059
|
+
raise KeyboardInterrupt()
|
|
1060
|
+
|
|
1061
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
mod.main()
|
|
1065
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1066
|
+
pass
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
# ============================================================================
|
|
1070
|
+
# Lock Release Execute Path Tests (lines 350-351)
|
|
1071
|
+
# ============================================================================
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def test_main_lock_release_success(monkeypatch, tmp_path):
|
|
1075
|
+
"""Test that successful lock release executes the delete (lines 350-351)."""
|
|
1076
|
+
mod = load_watcher_module()
|
|
1077
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
1078
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
1079
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
1080
|
+
|
|
1081
|
+
pid_file = tmp_path / "mod.pid"
|
|
1082
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
1083
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
1084
|
+
mod._active_conflicts.clear()
|
|
1085
|
+
|
|
1086
|
+
delete_calls = []
|
|
1087
|
+
|
|
1088
|
+
class FakeOKResult:
|
|
1089
|
+
data = [{"status": "ok"}]
|
|
1090
|
+
|
|
1091
|
+
class TrackingClient:
|
|
1092
|
+
def table(self, name):
|
|
1093
|
+
return self
|
|
1094
|
+
|
|
1095
|
+
def delete(self):
|
|
1096
|
+
delete_calls.append("delete")
|
|
1097
|
+
return self
|
|
1098
|
+
|
|
1099
|
+
def eq(self, *args):
|
|
1100
|
+
return self
|
|
1101
|
+
|
|
1102
|
+
def execute(self):
|
|
1103
|
+
delete_calls.append("execute")
|
|
1104
|
+
return None
|
|
1105
|
+
|
|
1106
|
+
def rpc(self, name, params):
|
|
1107
|
+
return type("Chain", (), {"execute": lambda self: FakeOKResult()})()
|
|
1108
|
+
|
|
1109
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: TrackingClient())
|
|
1110
|
+
|
|
1111
|
+
git_call_count = [0]
|
|
1112
|
+
|
|
1113
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1114
|
+
git_call_count[0] += 1
|
|
1115
|
+
if "user.name" in cmd:
|
|
1116
|
+
return b"test_user\n"
|
|
1117
|
+
if "branch" in cmd:
|
|
1118
|
+
return b"main\n"
|
|
1119
|
+
if "status" in cmd:
|
|
1120
|
+
# First iterations: file present, then: clean
|
|
1121
|
+
if git_call_count[0] <= 5:
|
|
1122
|
+
return b" M src/release_me.py\n"
|
|
1123
|
+
return b""
|
|
1124
|
+
return b""
|
|
1125
|
+
|
|
1126
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1127
|
+
|
|
1128
|
+
sleep_count = [0]
|
|
1129
|
+
|
|
1130
|
+
def mock_sleep(seconds):
|
|
1131
|
+
sleep_count[0] += 1
|
|
1132
|
+
if sleep_count[0] > 4:
|
|
1133
|
+
raise KeyboardInterrupt()
|
|
1134
|
+
|
|
1135
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
mod.main()
|
|
1139
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1140
|
+
pass
|
|
1141
|
+
|
|
1142
|
+
# The delete path should have been called
|
|
1143
|
+
assert "delete" in delete_calls or "execute" in delete_calls
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
# ============================================================================
|
|
1147
|
+
# Idle Timeout Direct Test (lines 381-382)
|
|
1148
|
+
# ============================================================================
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def test_main_idle_timeout_break(monkeypatch, tmp_path):
|
|
1152
|
+
"""Test that idle timeout causes main to break (lines 381-382)."""
|
|
1153
|
+
mod = load_watcher_module()
|
|
1154
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
1155
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
1156
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py", "--timeout", "1"])
|
|
1157
|
+
|
|
1158
|
+
pid_file = tmp_path / "mod.pid"
|
|
1159
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
1160
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
1161
|
+
|
|
1162
|
+
class FakeSupaClient:
|
|
1163
|
+
def table(self, name):
|
|
1164
|
+
return self
|
|
1165
|
+
|
|
1166
|
+
def delete(self):
|
|
1167
|
+
return self
|
|
1168
|
+
|
|
1169
|
+
def eq(self, *args):
|
|
1170
|
+
return self
|
|
1171
|
+
|
|
1172
|
+
def execute(self):
|
|
1173
|
+
return None
|
|
1174
|
+
|
|
1175
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
1176
|
+
|
|
1177
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1178
|
+
if "user.name" in cmd:
|
|
1179
|
+
return b"test_user\n"
|
|
1180
|
+
if "branch" in cmd:
|
|
1181
|
+
return b"main\n"
|
|
1182
|
+
return b""
|
|
1183
|
+
|
|
1184
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1185
|
+
|
|
1186
|
+
real_now = datetime.now
|
|
1187
|
+
offset = [timedelta()]
|
|
1188
|
+
|
|
1189
|
+
def advancing_sleep(seconds):
|
|
1190
|
+
offset[0] += timedelta(minutes=5)
|
|
1191
|
+
|
|
1192
|
+
def fake_now(*args, **kwargs):
|
|
1193
|
+
return real_now() + offset[0]
|
|
1194
|
+
|
|
1195
|
+
monkeypatch.setattr("time.sleep", advancing_sleep)
|
|
1196
|
+
# Patch datetime on the watcher module
|
|
1197
|
+
monkeypatch.setattr(
|
|
1198
|
+
watcher,
|
|
1199
|
+
"datetime",
|
|
1200
|
+
type(
|
|
1201
|
+
"FakeDT",
|
|
1202
|
+
(),
|
|
1203
|
+
{
|
|
1204
|
+
"now": staticmethod(fake_now),
|
|
1205
|
+
"fromisoformat": datetime.fromisoformat,
|
|
1206
|
+
},
|
|
1207
|
+
)(),
|
|
1208
|
+
raising=False,
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
try:
|
|
1212
|
+
mod.main()
|
|
1213
|
+
except (SystemExit, AttributeError, TypeError):
|
|
1214
|
+
pass
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
# ============================================================================
|
|
1218
|
+
# _scan_remote_locks Tests
|
|
1219
|
+
# ============================================================================
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
class FakeScanClient:
|
|
1223
|
+
"""Mock Supabase client for _scan_remote_locks tests."""
|
|
1224
|
+
|
|
1225
|
+
def __init__(self, data=None, raise_exc=None):
|
|
1226
|
+
self._data = data or []
|
|
1227
|
+
self._raise_exc = raise_exc
|
|
1228
|
+
|
|
1229
|
+
def table(self, *args, **kwargs):
|
|
1230
|
+
return self
|
|
1231
|
+
|
|
1232
|
+
def select(self, *args, **kwargs):
|
|
1233
|
+
if self._raise_exc:
|
|
1234
|
+
raise self._raise_exc
|
|
1235
|
+
return self
|
|
1236
|
+
|
|
1237
|
+
def execute(self):
|
|
1238
|
+
if self._raise_exc:
|
|
1239
|
+
raise self._raise_exc
|
|
1240
|
+
|
|
1241
|
+
class Resp:
|
|
1242
|
+
data = self._data
|
|
1243
|
+
|
|
1244
|
+
return Resp()
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def test_main_dashboard_fallback_message(monkeypatch, tmp_path, caplog):
|
|
1248
|
+
"""Test main() logs fallback dashboard message when server fails (line 382)."""
|
|
1249
|
+
mod = load_watcher_module()
|
|
1250
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "https://test.supabase.co")
|
|
1251
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "test_key")
|
|
1252
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_mod.py"])
|
|
1253
|
+
|
|
1254
|
+
pid_file = tmp_path / "mod.pid"
|
|
1255
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
1256
|
+
monkeypatch.setattr(watcher, "desktop_notify", None)
|
|
1257
|
+
|
|
1258
|
+
# Make _start_dashboard_server return None
|
|
1259
|
+
monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
|
|
1260
|
+
|
|
1261
|
+
class FakeSupaClient:
|
|
1262
|
+
def table(self, name):
|
|
1263
|
+
return self
|
|
1264
|
+
|
|
1265
|
+
def delete(self):
|
|
1266
|
+
return self
|
|
1267
|
+
|
|
1268
|
+
def eq(self, *args):
|
|
1269
|
+
return self
|
|
1270
|
+
|
|
1271
|
+
def execute(self):
|
|
1272
|
+
return None
|
|
1273
|
+
|
|
1274
|
+
def select(self, *args):
|
|
1275
|
+
return self
|
|
1276
|
+
|
|
1277
|
+
monkeypatch.setattr(watcher, "create_client", lambda url, key: FakeSupaClient())
|
|
1278
|
+
|
|
1279
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1280
|
+
if "user.name" in cmd:
|
|
1281
|
+
return b"test_user\n"
|
|
1282
|
+
if "branch" in cmd:
|
|
1283
|
+
return b"main\n"
|
|
1284
|
+
return b""
|
|
1285
|
+
|
|
1286
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1287
|
+
|
|
1288
|
+
sleep_count = [0]
|
|
1289
|
+
|
|
1290
|
+
def mock_sleep(seconds):
|
|
1291
|
+
sleep_count[0] += 1
|
|
1292
|
+
if sleep_count[0] > 1:
|
|
1293
|
+
raise KeyboardInterrupt()
|
|
1294
|
+
|
|
1295
|
+
monkeypatch.setattr("time.sleep", mock_sleep)
|
|
1296
|
+
|
|
1297
|
+
import logging
|
|
1298
|
+
|
|
1299
|
+
with caplog.at_level(logging.INFO, logger="collab.pycharm_watcher"):
|
|
1300
|
+
try:
|
|
1301
|
+
mod.main()
|
|
1302
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1303
|
+
pass
|
|
1304
|
+
|
|
1305
|
+
assert any("collab dashboard" in r.message for r in caplog.records)
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def test_main_exits_when_create_client_none(monkeypatch):
|
|
1309
|
+
mod = load_watcher_module()
|
|
1310
|
+
# Ensure main() will exit early with SystemExit when create_client is None
|
|
1311
|
+
mod.SUPABASE_URL = "https://example.invalid"
|
|
1312
|
+
mod.SUPABASE_ANON_KEY = "anon:fake"
|
|
1313
|
+
|
|
1314
|
+
# Use None for create_client to simulate missing dependency
|
|
1315
|
+
monkeypatch.setattr(watcher, "create_client", None)
|
|
1316
|
+
monkeypatch.setattr(watcher, "_start_dashboard_server", lambda: None)
|
|
1317
|
+
|
|
1318
|
+
# Avoid argparse picking up pytest args
|
|
1319
|
+
import sys as _sys
|
|
1320
|
+
|
|
1321
|
+
monkeypatch.setattr(_sys, "argv", ["collab"]) # safe minimal argv
|
|
1322
|
+
|
|
1323
|
+
import pytest
|
|
1324
|
+
|
|
1325
|
+
with pytest.raises(SystemExit):
|
|
1326
|
+
mod.main()
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def test_main_fallback_writes_plain_pid(monkeypatch, tmp_path):
|
|
1330
|
+
mod = load_watcher_module()
|
|
1331
|
+
# Simulate _write_pid_file raising so main falls back to plain integer write
|
|
1332
|
+
pid_file = tmp_path / ".daemon.pid"
|
|
1333
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
1334
|
+
|
|
1335
|
+
def _boom(pid):
|
|
1336
|
+
raise Exception("boom")
|
|
1337
|
+
|
|
1338
|
+
monkeypatch.setattr(watcher, "_write_pid_file", _boom)
|
|
1339
|
+
# Provide minimal values so main continues to client creation
|
|
1340
|
+
monkeypatch.setattr(watcher, "SUPABASE_URL", "x")
|
|
1341
|
+
monkeypatch.setattr(watcher, "SUPABASE_ANON_KEY", "y")
|
|
1342
|
+
monkeypatch.setattr(watcher, "_get_developer_id", lambda: "tester")
|
|
1343
|
+
monkeypatch.setattr(watcher, "create_client", lambda a, b: object())
|
|
1344
|
+
|
|
1345
|
+
# Make dashboard startup terminate the process early so we don't enter the full loop
|
|
1346
|
+
def _raise_sys_exit():
|
|
1347
|
+
raise SystemExit(0)
|
|
1348
|
+
|
|
1349
|
+
monkeypatch.setattr(watcher, "_start_dashboard_server", _raise_sys_exit)
|
|
1350
|
+
|
|
1351
|
+
# Ensure argparse in main() doesn't see pytest args
|
|
1352
|
+
monkeypatch.setattr("sys.argv", ["live_watcher"]) # type: ignore[arg-type]
|
|
1353
|
+
try:
|
|
1354
|
+
mod.main()
|
|
1355
|
+
except SystemExit:
|
|
1356
|
+
pass
|
|
1357
|
+
|
|
1358
|
+
# Fallback should have written the plain integer PID
|
|
1359
|
+
assert pid_file.exists()
|
|
1360
|
+
content = pid_file.read_text()
|
|
1361
|
+
assert content.strip().isdigit()
|
|
1362
|
+
# Clean up
|
|
1363
|
+
try:
|
|
1364
|
+
os.remove(pid_file)
|
|
1365
|
+
except Exception:
|
|
1366
|
+
pass
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
# -------------------------- Restored watcher tests --------------------------
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def test_live_locks_watcher_main_loop_gaps(monkeypatch):
|
|
1373
|
+
"""Cover lines 1591-1654 (Main loop control flow)."""
|
|
1374
|
+
mod = load_watcher_module()
|
|
1375
|
+
monkeypatch.setattr(watcher, "_get_parent_ide_pid_local", lambda: 999)
|
|
1376
|
+
# Ensure _is_process_alive is true for 999
|
|
1377
|
+
monkeypatch.setattr(watcher, "_is_process_alive", lambda p: int(p) == 999)
|
|
1378
|
+
|
|
1379
|
+
# Use a generator for side_effect simulation with monkeypatch
|
|
1380
|
+
def sleep_gen():
|
|
1381
|
+
yield None
|
|
1382
|
+
yield SystemExit()
|
|
1383
|
+
|
|
1384
|
+
gen = sleep_gen()
|
|
1385
|
+
monkeypatch.setattr("time.sleep", lambda x: next(gen))
|
|
1386
|
+
|
|
1387
|
+
# Mock the git/client creation to prevent external calls
|
|
1388
|
+
monkeypatch.setattr(watcher, "create_client", lambda *a: mock.MagicMock())
|
|
1389
|
+
monkeypatch.setattr(watcher, "_run_git_status_porcelain", lambda: [])
|
|
1390
|
+
|
|
1391
|
+
# Fix: remove attribute patches that don't exist in module
|
|
1392
|
+
with pytest.raises(SystemExit):
|
|
1393
|
+
mod.main()
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
# ---------------------------------------------------------------------------
|
|
1397
|
+
# Deep tests: main() label-display, startup, and loop branches
|
|
1398
|
+
# ---------------------------------------------------------------------------
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
# ---------------------------------------------------------------------------
|
|
1402
|
+
# main() — watcher-already-running label branches (lines 1681-1710)
|
|
1403
|
+
# ---------------------------------------------------------------------------
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def test_main_existing_watcher_label_lock_daemon(monkeypatch, tmp_path):
|
|
1407
|
+
"""Label 'lock-daemon' entry maps to 'python lock_client.py'."""
|
|
1408
|
+
mod = load_watcher_module()
|
|
1409
|
+
_setup_common(monkeypatch, mod)
|
|
1410
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1411
|
+
|
|
1412
|
+
pid_file = tmp_path / "test.pid"
|
|
1413
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1414
|
+
|
|
1415
|
+
# Simulate existing watcher with 'lock-daemon' entrypoint
|
|
1416
|
+
monkeypatch.setattr(
|
|
1417
|
+
mod,
|
|
1418
|
+
"_existing_watcher_running",
|
|
1419
|
+
lambda: (True, 1234, "python lock_client.py watch", "lock-daemon"),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
with pytest.raises(SystemExit):
|
|
1423
|
+
mod.main()
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def test_main_existing_watcher_label_pycharm_watcher(monkeypatch, tmp_path):
|
|
1427
|
+
"""Label 'pycharm-watcher' entry maps to descriptive path."""
|
|
1428
|
+
mod = load_watcher_module()
|
|
1429
|
+
_setup_common(monkeypatch, mod)
|
|
1430
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1431
|
+
|
|
1432
|
+
pid_file = tmp_path / "test.pid"
|
|
1433
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1434
|
+
|
|
1435
|
+
monkeypatch.setattr(
|
|
1436
|
+
mod,
|
|
1437
|
+
"_existing_watcher_running",
|
|
1438
|
+
lambda: (True, 5678, None, "pycharm-watcher"),
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
with pytest.raises(SystemExit):
|
|
1442
|
+
mod.main()
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def test_main_existing_watcher_label_from_cmdline(monkeypatch, tmp_path):
|
|
1446
|
+
"""When no entrypoint, label is derived from cmdline."""
|
|
1447
|
+
mod = load_watcher_module()
|
|
1448
|
+
_setup_common(monkeypatch, mod)
|
|
1449
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1450
|
+
|
|
1451
|
+
pid_file = tmp_path / "test.pid"
|
|
1452
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1453
|
+
|
|
1454
|
+
monkeypatch.setattr(
|
|
1455
|
+
mod,
|
|
1456
|
+
"_existing_watcher_running",
|
|
1457
|
+
lambda: (True, 9999, "python /some/path/live_locks_watcher.py", None),
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
with pytest.raises(SystemExit):
|
|
1461
|
+
mod.main()
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def test_main_existing_watcher_no_label(monkeypatch, tmp_path):
|
|
1465
|
+
"""With neither entrypoint nor cmdline, label-less message is shown."""
|
|
1466
|
+
mod = load_watcher_module()
|
|
1467
|
+
_setup_common(monkeypatch, mod)
|
|
1468
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1469
|
+
|
|
1470
|
+
pid_file = tmp_path / "test.pid"
|
|
1471
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1472
|
+
|
|
1473
|
+
monkeypatch.setattr(
|
|
1474
|
+
mod,
|
|
1475
|
+
"_existing_watcher_running",
|
|
1476
|
+
lambda: (True, 9999, None, None),
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
with pytest.raises(SystemExit):
|
|
1480
|
+
mod.main()
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def test_main_existing_watcher_label_other_entrypoint(monkeypatch, tmp_path):
|
|
1484
|
+
"""Other entrypoint string goes through _shorten_process_label."""
|
|
1485
|
+
mod = load_watcher_module()
|
|
1486
|
+
_setup_common(monkeypatch, mod)
|
|
1487
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1488
|
+
|
|
1489
|
+
pid_file = tmp_path / "test.pid"
|
|
1490
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1491
|
+
|
|
1492
|
+
monkeypatch.setattr(
|
|
1493
|
+
mod,
|
|
1494
|
+
"_existing_watcher_running",
|
|
1495
|
+
lambda: (True, 9999, None, "some-custom-entrypoint"),
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
with pytest.raises(SystemExit):
|
|
1499
|
+
mod.main()
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
# ---------------------------------------------------------------------------
|
|
1503
|
+
# main() — parent-pid detection branch (lines 1701-1710 region)
|
|
1504
|
+
# ---------------------------------------------------------------------------
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def test_main_parent_pid_from_cli_arg(monkeypatch, tmp_path):
|
|
1508
|
+
"""--parent-pid CLI arg sets parent_pid and logs 'Tied to parent PID via CLI'."""
|
|
1509
|
+
mod = load_watcher_module()
|
|
1510
|
+
_setup_common(monkeypatch, mod)
|
|
1511
|
+
_stub_supabase(monkeypatch, mod)
|
|
1512
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--parent-pid", "9999"])
|
|
1513
|
+
|
|
1514
|
+
pid_file = tmp_path / "test.pid"
|
|
1515
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1516
|
+
monkeypatch.setattr(
|
|
1517
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1518
|
+
)
|
|
1519
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1520
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1521
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1522
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: pid != 9999)
|
|
1523
|
+
|
|
1524
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1525
|
+
|
|
1526
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1527
|
+
return b""
|
|
1528
|
+
|
|
1529
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1530
|
+
|
|
1531
|
+
try:
|
|
1532
|
+
mod.main()
|
|
1533
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1534
|
+
pass
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def test_main_no_parent_pid_detected(monkeypatch, tmp_path):
|
|
1538
|
+
"""When no parent found, logs 'No IDE owner identified'."""
|
|
1539
|
+
mod = load_watcher_module()
|
|
1540
|
+
_setup_common(monkeypatch, mod)
|
|
1541
|
+
_stub_supabase(monkeypatch, mod)
|
|
1542
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1543
|
+
|
|
1544
|
+
pid_file = tmp_path / "test.pid"
|
|
1545
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1546
|
+
monkeypatch.setattr(
|
|
1547
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1548
|
+
)
|
|
1549
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1550
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1551
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1552
|
+
|
|
1553
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1554
|
+
|
|
1555
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1556
|
+
return b""
|
|
1557
|
+
|
|
1558
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1559
|
+
|
|
1560
|
+
try:
|
|
1561
|
+
mod.main()
|
|
1562
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1563
|
+
pass
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def test_main_write_pid_file_falls_back_to_plain_pid(monkeypatch, tmp_path):
|
|
1567
|
+
"""If _write_pid_file raises, falls back to writing raw PID integer."""
|
|
1568
|
+
mod = load_watcher_module()
|
|
1569
|
+
_setup_common(monkeypatch, mod)
|
|
1570
|
+
_stub_supabase(monkeypatch, mod)
|
|
1571
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1572
|
+
|
|
1573
|
+
pid_file = tmp_path / "test.pid"
|
|
1574
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1575
|
+
monkeypatch.setattr(
|
|
1576
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1577
|
+
)
|
|
1578
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1579
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1580
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1581
|
+
monkeypatch.setattr(
|
|
1582
|
+
mod, "_write_pid_file", mock.Mock(side_effect=RuntimeError("fail"))
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1586
|
+
|
|
1587
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1588
|
+
return b""
|
|
1589
|
+
|
|
1590
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1591
|
+
|
|
1592
|
+
try:
|
|
1593
|
+
mod.main()
|
|
1594
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1595
|
+
pass
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
# ---------------------------------------------------------------------------
|
|
1599
|
+
# main() — parent dead in the loop (lines 1839-1842 region)
|
|
1600
|
+
# ---------------------------------------------------------------------------
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def test_main_parent_pid_dies_breaks_loop(monkeypatch, tmp_path):
|
|
1604
|
+
"""When parent_pid is tracked and goes dead, loop exits gracefully."""
|
|
1605
|
+
mod = load_watcher_module()
|
|
1606
|
+
_setup_common(monkeypatch, mod)
|
|
1607
|
+
_stub_supabase(monkeypatch, mod)
|
|
1608
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1609
|
+
|
|
1610
|
+
pid_file = tmp_path / "test.pid"
|
|
1611
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1612
|
+
monkeypatch.setattr(
|
|
1613
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1614
|
+
)
|
|
1615
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1616
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1617
|
+
|
|
1618
|
+
# Simulate parent detection returning a specific PID
|
|
1619
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: 12345)
|
|
1620
|
+
# Mark parent as dead from the start
|
|
1621
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: False)
|
|
1622
|
+
|
|
1623
|
+
ticks = [0]
|
|
1624
|
+
|
|
1625
|
+
def mock_sleep(x):
|
|
1626
|
+
ticks[0] += 1
|
|
1627
|
+
if ticks[0] > 5:
|
|
1628
|
+
raise KeyboardInterrupt()
|
|
1629
|
+
|
|
1630
|
+
monkeypatch.setattr(mod.time, "sleep", mock_sleep)
|
|
1631
|
+
|
|
1632
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1633
|
+
return b""
|
|
1634
|
+
|
|
1635
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1636
|
+
|
|
1637
|
+
# Make datetime advance so parent check runs (>5s)
|
|
1638
|
+
real_now = datetime.now
|
|
1639
|
+
tick = [0]
|
|
1640
|
+
|
|
1641
|
+
def fast_now():
|
|
1642
|
+
tick[0] += 1
|
|
1643
|
+
return real_now() + timedelta(seconds=tick[0] * 10)
|
|
1644
|
+
|
|
1645
|
+
monkeypatch.setattr(
|
|
1646
|
+
mod,
|
|
1647
|
+
"datetime",
|
|
1648
|
+
type(
|
|
1649
|
+
"FDT",
|
|
1650
|
+
(),
|
|
1651
|
+
{"now": staticmethod(fast_now), "fromisoformat": datetime.fromisoformat},
|
|
1652
|
+
)(),
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
try:
|
|
1656
|
+
mod.main()
|
|
1657
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1658
|
+
pass
|
|
1659
|
+
# loop should have broken without error
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
# ---------------------------------------------------------------------------
|
|
1663
|
+
# main() — debug mode / COLLAB_DEBUG (lines 1811 region)
|
|
1664
|
+
# ---------------------------------------------------------------------------
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def test_main_debug_mode_via_env(monkeypatch, tmp_path):
|
|
1668
|
+
"""COLLAB_DEBUG=1 enables debug logging."""
|
|
1669
|
+
mod = load_watcher_module()
|
|
1670
|
+
_setup_common(monkeypatch, mod)
|
|
1671
|
+
_stub_supabase(monkeypatch, mod)
|
|
1672
|
+
monkeypatch.setenv("COLLAB_DEBUG", "1")
|
|
1673
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1674
|
+
|
|
1675
|
+
pid_file = tmp_path / "test.pid"
|
|
1676
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1677
|
+
monkeypatch.setattr(
|
|
1678
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1679
|
+
)
|
|
1680
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1681
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1682
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1683
|
+
|
|
1684
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1685
|
+
|
|
1686
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1687
|
+
return b""
|
|
1688
|
+
|
|
1689
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1690
|
+
|
|
1691
|
+
try:
|
|
1692
|
+
mod.main()
|
|
1693
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1694
|
+
pass
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def test_main_debug_mode_via_flag(monkeypatch, tmp_path):
|
|
1698
|
+
"""--debug flag enables debug logging."""
|
|
1699
|
+
mod = load_watcher_module()
|
|
1700
|
+
_setup_common(monkeypatch, mod)
|
|
1701
|
+
_stub_supabase(monkeypatch, mod)
|
|
1702
|
+
monkeypatch.setenv("COLLAB_DEBUG", "0")
|
|
1703
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--debug"])
|
|
1704
|
+
|
|
1705
|
+
pid_file = tmp_path / "test.pid"
|
|
1706
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1707
|
+
monkeypatch.setattr(
|
|
1708
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1709
|
+
)
|
|
1710
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1711
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1712
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1713
|
+
|
|
1714
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1715
|
+
|
|
1716
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1717
|
+
return b""
|
|
1718
|
+
|
|
1719
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1720
|
+
|
|
1721
|
+
try:
|
|
1722
|
+
mod.main()
|
|
1723
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1724
|
+
pass
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
# ---------------------------------------------------------------------------
|
|
1728
|
+
# main() — idle timeout with kept locks (line 1856-1857 region)
|
|
1729
|
+
# ---------------------------------------------------------------------------
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def test_main_initial_git_status_raises_ignores_exception(monkeypatch, tmp_path):
|
|
1733
|
+
"""If _run_git_status_porcelain raises during init after reconcile, exception is
|
|
1734
|
+
swallowed."""
|
|
1735
|
+
mod = load_watcher_module()
|
|
1736
|
+
_setup_common(monkeypatch, mod)
|
|
1737
|
+
_stub_supabase(monkeypatch, mod)
|
|
1738
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1739
|
+
|
|
1740
|
+
pid_file = tmp_path / "test.pid"
|
|
1741
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1742
|
+
monkeypatch.setattr(
|
|
1743
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1744
|
+
)
|
|
1745
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1746
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1747
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1748
|
+
|
|
1749
|
+
git_calls = [0]
|
|
1750
|
+
|
|
1751
|
+
def failing_git_status():
|
|
1752
|
+
git_calls[0] += 1
|
|
1753
|
+
if git_calls[0] == 1:
|
|
1754
|
+
raise RuntimeError("git not found")
|
|
1755
|
+
return set()
|
|
1756
|
+
|
|
1757
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", failing_git_status)
|
|
1758
|
+
_stub_loop_then_interrupt(monkeypatch, mod, max_ticks=1)
|
|
1759
|
+
|
|
1760
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1761
|
+
return b""
|
|
1762
|
+
|
|
1763
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1764
|
+
|
|
1765
|
+
try:
|
|
1766
|
+
mod.main()
|
|
1767
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1768
|
+
pass
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def test_main_timeout_dirty_status_raises_uses_local_set(monkeypatch, tmp_path):
|
|
1772
|
+
"""Idle-timeout fallback when timeout git-status check raises.
|
|
1773
|
+
|
|
1774
|
+
Falls back to _local_owned_locks.
|
|
1775
|
+
"""
|
|
1776
|
+
mod = load_watcher_module()
|
|
1777
|
+
_setup_common(monkeypatch, mod)
|
|
1778
|
+
_stub_supabase(monkeypatch, mod)
|
|
1779
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
|
|
1780
|
+
|
|
1781
|
+
pid_file = tmp_path / "test.pid"
|
|
1782
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1783
|
+
monkeypatch.setattr(
|
|
1784
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1785
|
+
)
|
|
1786
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1787
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1788
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1789
|
+
|
|
1790
|
+
call_count = [0]
|
|
1791
|
+
|
|
1792
|
+
def sometimes_failing_git():
|
|
1793
|
+
call_count[0] += 1
|
|
1794
|
+
# 1: init last_modified, 2: current_modified in loop, 3: timeout-check
|
|
1795
|
+
# lookup where we intentionally fail to hit the fallback branch.
|
|
1796
|
+
if call_count[0] == 3:
|
|
1797
|
+
raise RuntimeError("git failed")
|
|
1798
|
+
return set()
|
|
1799
|
+
|
|
1800
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", sometimes_failing_git)
|
|
1801
|
+
mod._local_owned_locks = {"src/file.py"}
|
|
1802
|
+
|
|
1803
|
+
real_now = datetime.now
|
|
1804
|
+
ticks = [0]
|
|
1805
|
+
|
|
1806
|
+
def fake_now():
|
|
1807
|
+
ticks[0] += 1
|
|
1808
|
+
# Advance enough so idle timeout is immediately exceeded in loop.
|
|
1809
|
+
return real_now() + timedelta(minutes=ticks[0] * 2)
|
|
1810
|
+
|
|
1811
|
+
monkeypatch.setattr(mod.time, "sleep", lambda x: None)
|
|
1812
|
+
monkeypatch.setattr(
|
|
1813
|
+
mod,
|
|
1814
|
+
"datetime",
|
|
1815
|
+
type(
|
|
1816
|
+
"FDT",
|
|
1817
|
+
(),
|
|
1818
|
+
{"now": staticmethod(fake_now), "fromisoformat": datetime.fromisoformat},
|
|
1819
|
+
)(),
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1823
|
+
return b""
|
|
1824
|
+
|
|
1825
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1826
|
+
|
|
1827
|
+
try:
|
|
1828
|
+
mod.main()
|
|
1829
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1830
|
+
pass
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
def test_main_timeout_with_kept_locks_logs_warning(monkeypatch, tmp_path):
|
|
1834
|
+
"""Idle timeout with dirty files logs warning about preserved locks."""
|
|
1835
|
+
mod = load_watcher_module()
|
|
1836
|
+
_setup_common(monkeypatch, mod)
|
|
1837
|
+
_stub_supabase(monkeypatch, mod)
|
|
1838
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py", "--timeout", "1"])
|
|
1839
|
+
|
|
1840
|
+
pid_file = tmp_path / "test.pid"
|
|
1841
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1842
|
+
monkeypatch.setattr(
|
|
1843
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1844
|
+
)
|
|
1845
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: None)
|
|
1846
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1847
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1848
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", lambda: {"src/dirty.py"})
|
|
1849
|
+
|
|
1850
|
+
# Inject some owned locks
|
|
1851
|
+
mod._local_owned_locks = {"src/dirty.py"}
|
|
1852
|
+
|
|
1853
|
+
real_now = datetime.now
|
|
1854
|
+
offset = [timedelta()]
|
|
1855
|
+
|
|
1856
|
+
def advancing_sleep(x):
|
|
1857
|
+
offset[0] += timedelta(minutes=5)
|
|
1858
|
+
|
|
1859
|
+
def fake_now():
|
|
1860
|
+
return real_now() + offset[0]
|
|
1861
|
+
|
|
1862
|
+
monkeypatch.setattr(mod.time, "sleep", advancing_sleep)
|
|
1863
|
+
monkeypatch.setattr(
|
|
1864
|
+
mod,
|
|
1865
|
+
"datetime",
|
|
1866
|
+
type(
|
|
1867
|
+
"FDT",
|
|
1868
|
+
(),
|
|
1869
|
+
{"now": staticmethod(fake_now), "fromisoformat": datetime.fromisoformat},
|
|
1870
|
+
)(),
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1874
|
+
return b""
|
|
1875
|
+
|
|
1876
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1877
|
+
|
|
1878
|
+
try:
|
|
1879
|
+
mod.main()
|
|
1880
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1881
|
+
pass
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def test_main_loop_git_status_exception_logs_and_continues(monkeypatch, tmp_path):
|
|
1885
|
+
"""Main loop logs git-status errors and continues via sleep/continue branch."""
|
|
1886
|
+
mod = load_watcher_module()
|
|
1887
|
+
_setup_common(monkeypatch, mod)
|
|
1888
|
+
_stub_supabase(monkeypatch, mod)
|
|
1889
|
+
monkeypatch.setattr(sys, "argv", ["live_locks_watcher.py"])
|
|
1890
|
+
|
|
1891
|
+
pid_file = tmp_path / "test.pid"
|
|
1892
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1893
|
+
monkeypatch.setattr(
|
|
1894
|
+
mod, "_existing_watcher_running", lambda: (False, None, None, None)
|
|
1895
|
+
)
|
|
1896
|
+
monkeypatch.setattr(mod, "_get_parent_ide_pid_local", lambda: 123)
|
|
1897
|
+
monkeypatch.setattr(mod, "_reconcile_on_startup", lambda client: None)
|
|
1898
|
+
monkeypatch.setattr(mod, "_scan_remote_locks", lambda client: None)
|
|
1899
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: True)
|
|
1900
|
+
|
|
1901
|
+
calls = [0]
|
|
1902
|
+
|
|
1903
|
+
def _git_status():
|
|
1904
|
+
calls[0] += 1
|
|
1905
|
+
if calls[0] == 1:
|
|
1906
|
+
return set() # initial last_modified
|
|
1907
|
+
raise RuntimeError("git status failed")
|
|
1908
|
+
|
|
1909
|
+
monkeypatch.setattr(mod, "_run_git_status_porcelain", _git_status)
|
|
1910
|
+
monkeypatch.setattr(
|
|
1911
|
+
mod.time, "sleep", lambda x: (_ for _ in ()).throw(KeyboardInterrupt())
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
1915
|
+
return b""
|
|
1916
|
+
|
|
1917
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
1918
|
+
|
|
1919
|
+
try:
|
|
1920
|
+
mod.main()
|
|
1921
|
+
except (SystemExit, KeyboardInterrupt):
|
|
1922
|
+
pass
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
watcher = load_watcher_module()
|