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,1890 @@
|
|
|
1
|
+
"""Targeted helper branch coverage tests for lock_client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import types
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from ._helpers import load_lock_client_module
|
|
13
|
+
|
|
14
|
+
mod = load_lock_client_module()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_emit_log_resilient_skips_bad_handlers_and_uses_stderr_fallback(monkeypatch):
|
|
18
|
+
"""_emit_log_resilient tolerates handler edge cases and falls back to stderr."""
|
|
19
|
+
|
|
20
|
+
class _FilterFalse(logging.Handler):
|
|
21
|
+
def filter(self, record):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
class _ClosedStreamHandler(logging.Handler):
|
|
25
|
+
stream = types.SimpleNamespace(closed=True)
|
|
26
|
+
|
|
27
|
+
def filter(self, record):
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
class _BoomHandler(logging.Handler):
|
|
31
|
+
stream = types.SimpleNamespace(closed=False)
|
|
32
|
+
|
|
33
|
+
def filter(self, record):
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
def handle(self, record):
|
|
37
|
+
raise RuntimeError("handler boom")
|
|
38
|
+
|
|
39
|
+
log = logging.Logger("collab.test.emit")
|
|
40
|
+
log.setLevel(logging.INFO)
|
|
41
|
+
log.propagate = False
|
|
42
|
+
|
|
43
|
+
high_level = logging.StreamHandler()
|
|
44
|
+
high_level.setLevel(logging.ERROR)
|
|
45
|
+
log.handlers = [high_level, _FilterFalse(), _ClosedStreamHandler(), _BoomHandler()]
|
|
46
|
+
|
|
47
|
+
writes = []
|
|
48
|
+
|
|
49
|
+
class _Stderr:
|
|
50
|
+
closed = False
|
|
51
|
+
|
|
52
|
+
def write(self, message):
|
|
53
|
+
writes.append(message)
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(mod.sys, "stderr", _Stderr())
|
|
56
|
+
|
|
57
|
+
mod._emit_log_resilient(log, logging.INFO, "hello %s", "world")
|
|
58
|
+
|
|
59
|
+
assert writes == ["INFO: hello world\n"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_emit_log_resilient_swallows_outer_exceptions():
|
|
63
|
+
"""_emit_log_resilient suppresses unexpected outer logging failures."""
|
|
64
|
+
|
|
65
|
+
class _BadLogger:
|
|
66
|
+
disabled = False
|
|
67
|
+
|
|
68
|
+
def getEffectiveLevel(self):
|
|
69
|
+
raise RuntimeError("bad level")
|
|
70
|
+
|
|
71
|
+
mod._emit_log_resilient(_BadLogger(), logging.INFO, "ignored")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_get_state_dir_non_test_mode_uses_shared_temp_dir(monkeypatch, tmp_path):
|
|
75
|
+
"""_get_state_dir uses the non-test temp-dir variant when not in test mode."""
|
|
76
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
77
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
78
|
+
monkeypatch.delenv("TESTING", raising=False)
|
|
79
|
+
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
|
80
|
+
monkeypatch.setattr(mod.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
81
|
+
|
|
82
|
+
state_dir = mod._get_state_dir()
|
|
83
|
+
state_name = os.path.basename(state_dir)
|
|
84
|
+
|
|
85
|
+
assert state_dir.startswith(str(tmp_path / "collab_runtime_"))
|
|
86
|
+
assert "_test_" not in state_name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_get_state_dir_handles_makedirs_error_and_import_fallback(
|
|
90
|
+
monkeypatch, tmp_path
|
|
91
|
+
):
|
|
92
|
+
"""Cover makedirs exception paths and the _COLLAB_ROOT fallback with stronger, less-
|
|
93
|
+
duplicative assertions."""
|
|
94
|
+
# 1) When COLLAB_STATE_DIR is provided but os.makedirs fails, we still
|
|
95
|
+
# return the provided env path (absolute) and the directory is not
|
|
96
|
+
# created due to the makedirs failure.
|
|
97
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(tmp_path / "state"))
|
|
98
|
+
monkeypatch.setattr(
|
|
99
|
+
mod.os,
|
|
100
|
+
"makedirs",
|
|
101
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no mkdir")),
|
|
102
|
+
)
|
|
103
|
+
got_env = mod._get_state_dir()
|
|
104
|
+
assert os.path.abspath(got_env) == os.path.abspath(str(tmp_path / "state"))
|
|
105
|
+
assert not os.path.isdir(got_env)
|
|
106
|
+
|
|
107
|
+
# 2) Default temp-dir branch (non-test mode) still returns a collab_runtime_
|
|
108
|
+
# path even if makedirs raises; ensure it's absolute and has the right
|
|
109
|
+
# basename shape (and not a test-suffixed name).
|
|
110
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
111
|
+
monkeypatch.setattr(mod.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
112
|
+
sd = mod._get_state_dir()
|
|
113
|
+
assert os.path.isabs(sd)
|
|
114
|
+
sd_name = os.path.basename(sd)
|
|
115
|
+
assert sd_name.startswith("collab_runtime_")
|
|
116
|
+
# The runtime name may include a test-suffix in CI/pytest runs; we only
|
|
117
|
+
# enforce the shared prefix shape here to avoid flakiness.
|
|
118
|
+
|
|
119
|
+
# 3) If hashlib cannot be imported, we should fall back to the configured
|
|
120
|
+
# _COLLAB_ROOT value (string form).
|
|
121
|
+
import builtins as _builtins
|
|
122
|
+
|
|
123
|
+
real_import = _builtins.__import__
|
|
124
|
+
|
|
125
|
+
def _fake_import(name, *args, **kwargs):
|
|
126
|
+
if name == "hashlib":
|
|
127
|
+
raise ImportError("no hashlib")
|
|
128
|
+
return real_import(name, *args, **kwargs)
|
|
129
|
+
|
|
130
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
131
|
+
got_fallback = mod._get_state_dir()
|
|
132
|
+
assert os.path.abspath(got_fallback) == os.path.abspath(str(mod._COLLAB_ROOT))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_quiet_console_loggers_swallow_setlevel_errors(monkeypatch):
|
|
136
|
+
"""Exercise enter/restore exception swallowing in _quiet_console_loggers."""
|
|
137
|
+
|
|
138
|
+
class BadLogger:
|
|
139
|
+
def __init__(self):
|
|
140
|
+
self.level = 0
|
|
141
|
+
self.propagate = True
|
|
142
|
+
|
|
143
|
+
def setLevel(self, _lvl):
|
|
144
|
+
raise RuntimeError("set level fail")
|
|
145
|
+
|
|
146
|
+
real_get_logger = mod.logging.getLogger
|
|
147
|
+
|
|
148
|
+
def _get_logger(name=None):
|
|
149
|
+
# logging.getLogger() can be called with no name by pytest internals.
|
|
150
|
+
if name is None:
|
|
151
|
+
return real_get_logger()
|
|
152
|
+
if name in {"httpx", "collab"}:
|
|
153
|
+
return BadLogger()
|
|
154
|
+
return real_get_logger(name)
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(mod.logging, "getLogger", _get_logger)
|
|
157
|
+
with mod._quiet_console_loggers(names=["httpx"]):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_get_state_dir_test_mode_suffix(monkeypatch, tmp_path):
|
|
162
|
+
"""When test mode is enabled the state-dir uses the '_test_' suffix."""
|
|
163
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
164
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "1")
|
|
165
|
+
monkeypatch.setenv("TESTING", "1")
|
|
166
|
+
monkeypatch.setenv("PYTEST_CURRENT_TEST", "1")
|
|
167
|
+
monkeypatch.setattr(mod.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
168
|
+
|
|
169
|
+
sd = mod._get_state_dir()
|
|
170
|
+
assert "collab_runtime_" in sd
|
|
171
|
+
assert "_test_" in sd
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_get_state_dir_with_env_creates_and_returns(monkeypatch, tmp_path):
|
|
175
|
+
"""When `COLLAB_STATE_DIR` is set, `_get_state_dir` should create and return it."""
|
|
176
|
+
target = tmp_path / "state_env"
|
|
177
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(target))
|
|
178
|
+
# Ensure no pre-existing dir
|
|
179
|
+
if target.exists():
|
|
180
|
+
import shutil as _sh
|
|
181
|
+
|
|
182
|
+
_sh.rmtree(target)
|
|
183
|
+
|
|
184
|
+
got = mod._get_state_dir()
|
|
185
|
+
assert os.path.abspath(got) == os.path.abspath(str(target))
|
|
186
|
+
assert os.path.isdir(got)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_resolve_runtime_root_prefers_COLLAB_HOME(monkeypatch, tmp_path):
|
|
190
|
+
"""`_resolve_runtime_root` should return `COLLAB_HOME` when present."""
|
|
191
|
+
home = tmp_path / "home_dir"
|
|
192
|
+
monkeypatch.setenv("COLLAB_HOME", str(home))
|
|
193
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
194
|
+
|
|
195
|
+
got = mod._resolve_runtime_root(mod._PROJECT_ROOT)
|
|
196
|
+
assert os.path.abspath(got) == os.path.abspath(str(home))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_resolve_runtime_root_prefers_COLLAB_STATE_DIR_when_no_home(
|
|
200
|
+
monkeypatch, tmp_path
|
|
201
|
+
):
|
|
202
|
+
"""`_resolve_runtime_root` should return `COLLAB_STATE_DIR` if `COLLAB_HOME`
|
|
203
|
+
absent."""
|
|
204
|
+
monkeypatch.delenv("COLLAB_HOME", raising=False)
|
|
205
|
+
sd = tmp_path / "state_dir"
|
|
206
|
+
monkeypatch.setenv("COLLAB_STATE_DIR", str(sd))
|
|
207
|
+
|
|
208
|
+
got = mod._resolve_runtime_root(mod._PROJECT_ROOT)
|
|
209
|
+
assert os.path.abspath(got) == os.path.abspath(str(sd))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_get_state_dir_fallback_str_raises_uses_project_root(monkeypatch):
|
|
213
|
+
"""If `_COLLAB_ROOT` exists but its `str()` raises, fall back to project root."""
|
|
214
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
215
|
+
|
|
216
|
+
import builtins as _builtins
|
|
217
|
+
|
|
218
|
+
real_import = _builtins.__import__
|
|
219
|
+
|
|
220
|
+
def _fake_import(name, *a, **k):
|
|
221
|
+
if name == "hashlib":
|
|
222
|
+
raise ImportError("no hashlib")
|
|
223
|
+
return real_import(name, *a, **k)
|
|
224
|
+
|
|
225
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
226
|
+
|
|
227
|
+
class _BadStr:
|
|
228
|
+
def __str__(self):
|
|
229
|
+
raise RuntimeError("bad str")
|
|
230
|
+
|
|
231
|
+
# Ensure _COLLAB_ROOT is present but its str() raises
|
|
232
|
+
monkeypatch.setattr(mod, "_COLLAB_ROOT", _BadStr(), raising=False)
|
|
233
|
+
|
|
234
|
+
got = mod._get_state_dir()
|
|
235
|
+
assert os.path.abspath(got) == os.path.abspath(mod._PROJECT_ROOT)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_get_state_dir_import_failure_no_fallback_returns_project_root(
|
|
239
|
+
monkeypatch, tmp_path
|
|
240
|
+
):
|
|
241
|
+
"""When hashlib.sha1 raises and `_COLLAB_ROOT` is missing, return project root."""
|
|
242
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
243
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
244
|
+
monkeypatch.delenv("TESTING", raising=False)
|
|
245
|
+
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
|
246
|
+
|
|
247
|
+
# Ensure no _COLLAB_ROOT to exercise the project-root fallback
|
|
248
|
+
monkeypatch.delattr(mod, "_COLLAB_ROOT", raising=False)
|
|
249
|
+
|
|
250
|
+
# Replace hashlib with a fake that raises when sha1 is invoked
|
|
251
|
+
fake_hashlib = types.SimpleNamespace(
|
|
252
|
+
sha1=lambda *a, **k: (_ for _ in ()).throw(RuntimeError("sha1 fail"))
|
|
253
|
+
)
|
|
254
|
+
monkeypatch.setitem(sys.modules, "hashlib", fake_hashlib)
|
|
255
|
+
|
|
256
|
+
# Keep tempfile deterministic for the sd branch
|
|
257
|
+
monkeypatch.setattr(mod.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
258
|
+
|
|
259
|
+
got = mod._get_state_dir()
|
|
260
|
+
assert os.path.abspath(got) == os.path.abspath(mod._PROJECT_ROOT)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_get_state_dir_abspath_failure_returns_getcwd(monkeypatch):
|
|
264
|
+
"""If `os.path.abspath` raises, `_get_state_dir` falls back to `os.getcwd()`."""
|
|
265
|
+
monkeypatch.delenv("COLLAB_STATE_DIR", raising=False)
|
|
266
|
+
monkeypatch.delenv("COLLAB_TEST_MODE", raising=False)
|
|
267
|
+
monkeypatch.delenv("TESTING", raising=False)
|
|
268
|
+
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
|
269
|
+
|
|
270
|
+
monkeypatch.delattr(mod, "_COLLAB_ROOT", raising=False)
|
|
271
|
+
|
|
272
|
+
fake_hashlib = types.SimpleNamespace(
|
|
273
|
+
sha1=lambda *a, **k: (_ for _ in ()).throw(RuntimeError("sha1"))
|
|
274
|
+
)
|
|
275
|
+
monkeypatch.setitem(sys.modules, "hashlib", fake_hashlib)
|
|
276
|
+
|
|
277
|
+
# Force abspath to raise so we hit the final os.getcwd() fallback
|
|
278
|
+
monkeypatch.setattr(
|
|
279
|
+
mod.os.path,
|
|
280
|
+
"abspath",
|
|
281
|
+
lambda p: (_ for _ in ()).throw(RuntimeError("abspath fail")),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
got = mod._get_state_dir()
|
|
285
|
+
assert got == os.getcwd()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_get_session_token_component_fallbacks(monkeypatch):
|
|
289
|
+
"""Cover session token fallbacks for developer/hostname/path exceptions."""
|
|
290
|
+
|
|
291
|
+
class BadDev:
|
|
292
|
+
def __str__(self):
|
|
293
|
+
raise RuntimeError("bad dev")
|
|
294
|
+
|
|
295
|
+
c = mod.LockClient(local_only=True)
|
|
296
|
+
c.developer_id = BadDev()
|
|
297
|
+
|
|
298
|
+
monkeypatch.setattr(
|
|
299
|
+
mod.socket,
|
|
300
|
+
"gethostname",
|
|
301
|
+
lambda: (_ for _ in ()).throw(RuntimeError("no host")),
|
|
302
|
+
)
|
|
303
|
+
monkeypatch.setattr(
|
|
304
|
+
mod.os.path, "abspath", lambda p: (_ for _ in ()).throw(RuntimeError("no path"))
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
token = c._get_session_token()
|
|
308
|
+
assert isinstance(token, str)
|
|
309
|
+
assert len(token) == 16
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_is_same_machine_token_env_fallback_with_git_error(monkeypatch):
|
|
313
|
+
"""Cover _is_same_machine_token git exception branch + env-user candidate match."""
|
|
314
|
+
c = mod.LockClient(local_only=True)
|
|
315
|
+
c.developer_id = None
|
|
316
|
+
|
|
317
|
+
monkeypatch.setattr(mod.socket, "gethostname", lambda: "hostA")
|
|
318
|
+
monkeypatch.setattr(mod.os.path, "abspath", lambda p: "C:/repo")
|
|
319
|
+
monkeypatch.setenv("USERNAME", "alice")
|
|
320
|
+
monkeypatch.setattr(
|
|
321
|
+
mod.subprocess,
|
|
322
|
+
"check_output",
|
|
323
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("git fail")),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
import hashlib
|
|
327
|
+
|
|
328
|
+
seed = "alice:hosta:c:/repo"
|
|
329
|
+
expected = hashlib.sha256(seed.encode()).hexdigest()[:16]
|
|
330
|
+
assert c._is_same_machine_token(expected) is True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_get_cmdline_for_pid_windows_wmic_and_powershell_paths(monkeypatch):
|
|
334
|
+
"""Cover WMIC success, WMIC failure fallback, and PowerShell success branch."""
|
|
335
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
336
|
+
# Mock psutil to raise so we fall through to the Windows-specific paths.
|
|
337
|
+
# On CI, psutil is installed and psutil.Process(PID) would return a real
|
|
338
|
+
# cmdline for an existing process, bypassing the mocked Windows fallbacks.
|
|
339
|
+
monkeypatch.setitem(
|
|
340
|
+
sys.modules,
|
|
341
|
+
"psutil",
|
|
342
|
+
types.SimpleNamespace(
|
|
343
|
+
Process=lambda pid: (_ for _ in ()).throw(RuntimeError("mocked"))
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
monkeypatch.setattr(
|
|
347
|
+
mod.shutil, "which", lambda exe: "wmic" if exe == "wmic" else None
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# WMIC success path
|
|
351
|
+
monkeypatch.setattr(
|
|
352
|
+
mod.subprocess,
|
|
353
|
+
"check_output",
|
|
354
|
+
lambda *a, **k: "CommandLine\npython watcher.py\n",
|
|
355
|
+
)
|
|
356
|
+
got = mod.LockClient._get_cmdline_for_pid(111)
|
|
357
|
+
assert "watcher.py" in (got or "")
|
|
358
|
+
|
|
359
|
+
# WMIC failure then PowerShell success
|
|
360
|
+
def _check_output(args, *a, **k):
|
|
361
|
+
if args and args[0] == "wmic":
|
|
362
|
+
raise RuntimeError("wmic fail")
|
|
363
|
+
if args and args[0] == "powershell":
|
|
364
|
+
return "python from-powershell"
|
|
365
|
+
return ""
|
|
366
|
+
|
|
367
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
|
|
368
|
+
got2 = mod.LockClient._get_cmdline_for_pid(222)
|
|
369
|
+
assert got2 == "python from-powershell"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_get_cmdline_for_pid_windows_outer_fallback_exception(monkeypatch):
|
|
373
|
+
"""Cover outer Windows fallback exception branch (returns None)."""
|
|
374
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
375
|
+
# Mock psutil to raise so we fall through to the Windows-specific paths.
|
|
376
|
+
# On CI, psutil is installed and psutil.Process(PID) would return a real
|
|
377
|
+
# cmdline for an existing process, bypassing the mocked Windows fallbacks.
|
|
378
|
+
monkeypatch.setitem(
|
|
379
|
+
sys.modules,
|
|
380
|
+
"psutil",
|
|
381
|
+
types.SimpleNamespace(
|
|
382
|
+
Process=lambda pid: (_ for _ in ()).throw(RuntimeError("mocked"))
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
monkeypatch.setattr(
|
|
386
|
+
mod.shutil,
|
|
387
|
+
"which",
|
|
388
|
+
lambda exe: (_ for _ in ()).throw(RuntimeError("which fail")),
|
|
389
|
+
)
|
|
390
|
+
assert mod.LockClient._get_cmdline_for_pid(333) is None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_write_pid_fsync_exception_branch(monkeypatch, tmp_path):
|
|
394
|
+
"""Cover fsync exception handling during PID write."""
|
|
395
|
+
pid_file = tmp_path / "daemon.pid"
|
|
396
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
397
|
+
monkeypatch.setattr(
|
|
398
|
+
mod.os, "fsync", lambda _fd: (_ for _ in ()).throw(RuntimeError("fsync fail"))
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
mod.LockClient._write_pid(4242)
|
|
402
|
+
assert pid_file.exists()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _install_fake_ctypes(monkeypatch, kernel32):
|
|
406
|
+
fake_wintypes = types.SimpleNamespace(
|
|
407
|
+
LARGE_INTEGER=type("LARGE_INTEGER", (), {}),
|
|
408
|
+
DWORD=lambda v: v,
|
|
409
|
+
ULARGE_INTEGER=type("ULARGE_INTEGER", (), {}),
|
|
410
|
+
BOOL=lambda v: v,
|
|
411
|
+
)
|
|
412
|
+
fake_ctypes = types.SimpleNamespace(
|
|
413
|
+
Structure=type("Structure", (), {}),
|
|
414
|
+
POINTER=lambda x: x,
|
|
415
|
+
byref=lambda x: x,
|
|
416
|
+
sizeof=lambda x: 1024,
|
|
417
|
+
c_size_t=lambda v: v,
|
|
418
|
+
c_void_p=type("c_void_p", (), {}),
|
|
419
|
+
windll=types.SimpleNamespace(kernel32=kernel32),
|
|
420
|
+
WINFUNCTYPE=lambda *a: (lambda f: f),
|
|
421
|
+
wintypes=fake_wintypes,
|
|
422
|
+
)
|
|
423
|
+
monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
|
|
424
|
+
monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_assign_to_job_object_create_fails(monkeypatch):
|
|
428
|
+
"""Cover job-object create failure branch."""
|
|
429
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
430
|
+
|
|
431
|
+
class K32:
|
|
432
|
+
def CreateJobObjectW(self, a, b):
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
_install_fake_ctypes(monkeypatch, K32())
|
|
436
|
+
mod.LockClient._assign_to_job_object()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_assign_to_job_object_assign_success_and_failure(monkeypatch):
|
|
440
|
+
"""Cover AssignProcessToJobObject success and failure branches."""
|
|
441
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
442
|
+
|
|
443
|
+
class K32Ok:
|
|
444
|
+
def CreateJobObjectW(self, a, b):
|
|
445
|
+
return 1
|
|
446
|
+
|
|
447
|
+
def SetInformationJobObject(self, *a, **k):
|
|
448
|
+
return True
|
|
449
|
+
|
|
450
|
+
def GetCurrentProcess(self):
|
|
451
|
+
return 2
|
|
452
|
+
|
|
453
|
+
def AssignProcessToJobObject(self, *a, **k):
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
_install_fake_ctypes(monkeypatch, K32Ok())
|
|
457
|
+
mod.LockClient._assign_to_job_object()
|
|
458
|
+
|
|
459
|
+
class K32Fail(K32Ok):
|
|
460
|
+
def AssignProcessToJobObject(self, *a, **k):
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
_install_fake_ctypes(monkeypatch, K32Fail())
|
|
464
|
+
mod.LockClient._assign_to_job_object()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_is_process_alive_windows_fallback_branches(monkeypatch):
|
|
468
|
+
"""Cover Win32 API still-active/access-denied/tasklist-exception branches."""
|
|
469
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
470
|
+
|
|
471
|
+
# Make psutil path unavailable quickly.
|
|
472
|
+
import builtins as _builtins
|
|
473
|
+
|
|
474
|
+
real_import = _builtins.__import__
|
|
475
|
+
|
|
476
|
+
def _fake_import(name, *a, **k):
|
|
477
|
+
if name == "psutil":
|
|
478
|
+
raise ImportError("no psutil")
|
|
479
|
+
return real_import(name, *a, **k)
|
|
480
|
+
|
|
481
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
482
|
+
|
|
483
|
+
class K32Active:
|
|
484
|
+
def OpenProcess(self, *a, **k):
|
|
485
|
+
return 99
|
|
486
|
+
|
|
487
|
+
def GetExitCodeProcess(self, _h, ec):
|
|
488
|
+
ec.value = 259
|
|
489
|
+
return True
|
|
490
|
+
|
|
491
|
+
def CloseHandle(self, _h):
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
def GetLastError(self):
|
|
495
|
+
return 0
|
|
496
|
+
|
|
497
|
+
fake_ctypes = types.SimpleNamespace(
|
|
498
|
+
c_ulong=lambda v=0: types.SimpleNamespace(value=v),
|
|
499
|
+
byref=lambda x: x,
|
|
500
|
+
windll=types.SimpleNamespace(kernel32=K32Active()),
|
|
501
|
+
)
|
|
502
|
+
monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
|
|
503
|
+
assert mod.LockClient._is_process_alive(1111) is True
|
|
504
|
+
|
|
505
|
+
class K32Denied(K32Active):
|
|
506
|
+
def OpenProcess(self, *a, **k):
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
def GetLastError(self):
|
|
510
|
+
return 5
|
|
511
|
+
|
|
512
|
+
fake_ctypes2 = types.SimpleNamespace(
|
|
513
|
+
c_ulong=lambda v=0: types.SimpleNamespace(value=v),
|
|
514
|
+
byref=lambda x: x,
|
|
515
|
+
windll=types.SimpleNamespace(kernel32=K32Denied()),
|
|
516
|
+
)
|
|
517
|
+
monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes2)
|
|
518
|
+
assert mod.LockClient._is_process_alive(2222) is True
|
|
519
|
+
|
|
520
|
+
# Force ctypes path to fail too; then tasklist exception -> False
|
|
521
|
+
monkeypatch.setitem(sys.modules, "ctypes", types.SimpleNamespace(windll=None))
|
|
522
|
+
monkeypatch.setattr(
|
|
523
|
+
mod.subprocess,
|
|
524
|
+
"check_output",
|
|
525
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("tasklist fail")),
|
|
526
|
+
)
|
|
527
|
+
assert mod.LockClient._is_process_alive(3333) is False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_scan_remote_locks_logs_owned_locks(monkeypatch):
|
|
531
|
+
"""_scan_remote_locks logs only locks owned by the current developer."""
|
|
532
|
+
c = mod.LockClient(local_only=True)
|
|
533
|
+
c.developer_id = "alice"
|
|
534
|
+
|
|
535
|
+
class _FakeTable:
|
|
536
|
+
def select(self, *_a, **_k):
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
def execute(self):
|
|
540
|
+
return type(
|
|
541
|
+
"R",
|
|
542
|
+
(),
|
|
543
|
+
{
|
|
544
|
+
"data": [
|
|
545
|
+
{
|
|
546
|
+
"developer_id": "alice",
|
|
547
|
+
"file_path": "src/a.py",
|
|
548
|
+
"branch_name": "feat",
|
|
549
|
+
"reason": "manual",
|
|
550
|
+
},
|
|
551
|
+
{"developer_id": "bob", "file_path": "src/b.py"},
|
|
552
|
+
{"developer_id": "alice", "file_path": ""},
|
|
553
|
+
]
|
|
554
|
+
},
|
|
555
|
+
)()
|
|
556
|
+
|
|
557
|
+
class _FakeClient:
|
|
558
|
+
def table(self, _name):
|
|
559
|
+
return _FakeTable()
|
|
560
|
+
|
|
561
|
+
c._client = _FakeClient()
|
|
562
|
+
info_calls = []
|
|
563
|
+
debug_calls = []
|
|
564
|
+
monkeypatch.setattr(mod.logger, "info", lambda *a, **k: info_calls.append(a))
|
|
565
|
+
monkeypatch.setattr(mod.logger, "debug", lambda *a, **k: debug_calls.append(a))
|
|
566
|
+
|
|
567
|
+
c._scan_remote_locks()
|
|
568
|
+
assert any("[LOCKED]" in str(call[0]) for call in debug_calls)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def test_scan_remote_locks_handles_exceptions(monkeypatch):
|
|
572
|
+
"""_scan_remote_locks catches exceptions and logs debug fallback."""
|
|
573
|
+
c = mod.LockClient(local_only=True)
|
|
574
|
+
c.developer_id = "alice"
|
|
575
|
+
|
|
576
|
+
class _FailClient:
|
|
577
|
+
def table(self, _name):
|
|
578
|
+
raise RuntimeError("db down")
|
|
579
|
+
|
|
580
|
+
c._client = _FailClient()
|
|
581
|
+
c._scan_remote_locks() # no raise; covers exception branch
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@pytest.mark.skipif(
|
|
585
|
+
sys.platform != "win32", reason="Windows-specific process discovery fallback"
|
|
586
|
+
)
|
|
587
|
+
def test_discover_running_watchers_fallback_branches(monkeypatch):
|
|
588
|
+
"""Cover fallback parser branches.
|
|
589
|
+
|
|
590
|
+
Includes blank lines, inspect failures, and cmdline filters.
|
|
591
|
+
"""
|
|
592
|
+
# Win32 fallback line-empty continue path
|
|
593
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
594
|
+
monkeypatch.setitem(sys.modules, "psutil", None)
|
|
595
|
+
|
|
596
|
+
def _run_win(cmd, **kwargs):
|
|
597
|
+
return types.SimpleNamespace(
|
|
598
|
+
stdout='\n"python.exe","4321","Console","1","1 K"\n', returncode=0
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_win)
|
|
602
|
+
c = mod.LockClient(local_only=True)
|
|
603
|
+
monkeypatch.setattr(
|
|
604
|
+
c,
|
|
605
|
+
"_get_cmdline_for_pid",
|
|
606
|
+
lambda pid: (
|
|
607
|
+
"python .collab/core/lock_client.py watch " f"--pid-file {mod.PID_FILE}"
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
out = c._discover_running_watchers()
|
|
611
|
+
assert 4321 in out
|
|
612
|
+
|
|
613
|
+
# Unix fallback: parse process list plus cmdline none/non-matcher and
|
|
614
|
+
# run-exception branch.
|
|
615
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
616
|
+
monkeypatch.setitem(sys.modules, "psutil", None)
|
|
617
|
+
|
|
618
|
+
def _run_unix(cmd, **kwargs):
|
|
619
|
+
return types.SimpleNamespace(
|
|
620
|
+
stdout="\n111 python a\n222 python b\n", returncode=0
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_unix)
|
|
624
|
+
|
|
625
|
+
def _cmd(pid):
|
|
626
|
+
if pid == 111:
|
|
627
|
+
return None
|
|
628
|
+
if pid == 222:
|
|
629
|
+
return "python not-a-watcher"
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
monkeypatch.setattr(c, "_get_cmdline_for_pid", _cmd)
|
|
633
|
+
out2 = c._discover_running_watchers()
|
|
634
|
+
assert out2 == []
|
|
635
|
+
|
|
636
|
+
monkeypatch.setattr(
|
|
637
|
+
mod.subprocess,
|
|
638
|
+
"run",
|
|
639
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("ps fail")),
|
|
640
|
+
)
|
|
641
|
+
assert c._discover_running_watchers() == []
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_get_process_info_local_wmic_and_tasklist_fail_branches(monkeypatch):
|
|
645
|
+
"""Cover WMIC exception and tasklist exception fallback to (None, None)."""
|
|
646
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
647
|
+
|
|
648
|
+
c = mod.LockClient(local_only=True)
|
|
649
|
+
|
|
650
|
+
import builtins as _builtins
|
|
651
|
+
|
|
652
|
+
real_import = _builtins.__import__
|
|
653
|
+
|
|
654
|
+
def _fake_import(name, *a, **k):
|
|
655
|
+
if name == "psutil":
|
|
656
|
+
raise ImportError("no psutil")
|
|
657
|
+
return real_import(name, *a, **k)
|
|
658
|
+
|
|
659
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
660
|
+
monkeypatch.setattr(
|
|
661
|
+
mod.shutil, "which", lambda exe: "wmic" if exe == "wmic" else None
|
|
662
|
+
)
|
|
663
|
+
monkeypatch.setattr(
|
|
664
|
+
mod.subprocess,
|
|
665
|
+
"run",
|
|
666
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("wmic fail")),
|
|
667
|
+
)
|
|
668
|
+
monkeypatch.setattr(
|
|
669
|
+
mod.subprocess,
|
|
670
|
+
"check_output",
|
|
671
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("tasklist fail")),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
assert c._get_process_info_local(9999) == (None, None)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_cleanup_orphaned_processes_windows_and_unix_branches(monkeypatch):
|
|
678
|
+
"""Cover cleanup_orphaned_processes fallback branches on Windows and Unix."""
|
|
679
|
+
c = mod.LockClient(local_only=True)
|
|
680
|
+
|
|
681
|
+
# Windows branch: malformed tasklist PID, image-scan exception, psutil fallback,
|
|
682
|
+
# WMIC exception path, and no-wmic debug path.
|
|
683
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
684
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
685
|
+
|
|
686
|
+
def _run_win(args, **kwargs):
|
|
687
|
+
if args and args[0] == "tasklist" and "IMAGENAME" in args:
|
|
688
|
+
image = args[2].split()[-1]
|
|
689
|
+
if image == "pythonw.exe":
|
|
690
|
+
raise RuntimeError("scan fail")
|
|
691
|
+
return types.SimpleNamespace(
|
|
692
|
+
stdout=(
|
|
693
|
+
'"python.exe","1111","Console","1","1 K"\n'
|
|
694
|
+
'"python.exe","notint","Console","1","1 K"\n'
|
|
695
|
+
'"python.exe","2222","Console","1","1 K"\n'
|
|
696
|
+
),
|
|
697
|
+
returncode=0,
|
|
698
|
+
)
|
|
699
|
+
if args and args[0] == "wmic":
|
|
700
|
+
raise RuntimeError("wmic fail")
|
|
701
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
702
|
+
|
|
703
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_win)
|
|
704
|
+
|
|
705
|
+
# psutil present: one PID disappears, one PID generic exception => inspected False
|
|
706
|
+
class _Psutil:
|
|
707
|
+
class NoSuchProcess(Exception):
|
|
708
|
+
pass
|
|
709
|
+
|
|
710
|
+
class Process:
|
|
711
|
+
def __init__(self, pid):
|
|
712
|
+
self.pid = pid
|
|
713
|
+
|
|
714
|
+
def cmdline(self):
|
|
715
|
+
if self.pid == 1111:
|
|
716
|
+
raise _Psutil.NoSuchProcess()
|
|
717
|
+
raise RuntimeError("cmdline fail")
|
|
718
|
+
|
|
719
|
+
monkeypatch.setitem(sys.modules, "psutil", _Psutil)
|
|
720
|
+
|
|
721
|
+
which_state = {"n": 0}
|
|
722
|
+
|
|
723
|
+
def _which(exe):
|
|
724
|
+
# First pid path uses WMIC and fails; second has no WMIC to hit skip-log branch.
|
|
725
|
+
if exe == "wmic":
|
|
726
|
+
which_state["n"] += 1
|
|
727
|
+
return "wmic" if which_state["n"] == 1 else None
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
monkeypatch.setattr(mod.shutil, "which", _which)
|
|
731
|
+
|
|
732
|
+
c.cleanup_orphaned_processes()
|
|
733
|
+
|
|
734
|
+
# Unix branch: ProcessLookupError path and scanner exception path.
|
|
735
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
736
|
+
|
|
737
|
+
def _run_unix(args, **kwargs):
|
|
738
|
+
return types.SimpleNamespace(
|
|
739
|
+
stdout="u 3333 0 0 python collab_test_lock_client\n", returncode=0
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_unix)
|
|
743
|
+
monkeypatch.setattr(
|
|
744
|
+
mod.os, "kill", lambda pid, sig: (_ for _ in ()).throw(ProcessLookupError())
|
|
745
|
+
)
|
|
746
|
+
c.cleanup_orphaned_processes()
|
|
747
|
+
|
|
748
|
+
monkeypatch.setattr(
|
|
749
|
+
mod.subprocess,
|
|
750
|
+
"run",
|
|
751
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("ps fail")),
|
|
752
|
+
)
|
|
753
|
+
c.cleanup_orphaned_processes()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def test_get_cmdline_for_pid_importerror_and_proc_parse(monkeypatch):
|
|
757
|
+
"""Cover psutil import-exception fallback and /proc null-separated parsing."""
|
|
758
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
759
|
+
|
|
760
|
+
import builtins as _builtins
|
|
761
|
+
|
|
762
|
+
real_import = _builtins.__import__
|
|
763
|
+
|
|
764
|
+
def _fake_import(name, *a, **k):
|
|
765
|
+
if name == "psutil":
|
|
766
|
+
raise RuntimeError("import failed")
|
|
767
|
+
return real_import(name, *a, **k)
|
|
768
|
+
|
|
769
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
770
|
+
|
|
771
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda p: p == "/proc/555/cmdline")
|
|
772
|
+
|
|
773
|
+
class _FH:
|
|
774
|
+
def __enter__(self):
|
|
775
|
+
return self
|
|
776
|
+
|
|
777
|
+
def __exit__(self, exc_type, exc, tb):
|
|
778
|
+
return False
|
|
779
|
+
|
|
780
|
+
def read(self):
|
|
781
|
+
return b"python\x00.collab/core/lock_client.py\x00watch\x00"
|
|
782
|
+
|
|
783
|
+
monkeypatch.setattr(mod, "open", lambda *a, **k: _FH(), raising=False)
|
|
784
|
+
|
|
785
|
+
got = mod.LockClient._get_cmdline_for_pid(555)
|
|
786
|
+
assert got == "python .collab/core/lock_client.py watch"
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows job object assignment")
|
|
790
|
+
def test_assign_to_job_object_success_and_assign_failure(monkeypatch):
|
|
791
|
+
"""Cover GetCurrentProcess/AssignProcessToJobObject success and failure paths using
|
|
792
|
+
real ctypes."""
|
|
793
|
+
import ctypes as _real_ctypes
|
|
794
|
+
|
|
795
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
796
|
+
# Do NOT delete ctypes from sys.modules - we want to patch the SAME module instance
|
|
797
|
+
# that the function will import.
|
|
798
|
+
|
|
799
|
+
call_log = []
|
|
800
|
+
|
|
801
|
+
class _Fn:
|
|
802
|
+
def __init__(self, val, name=""):
|
|
803
|
+
self._val = val
|
|
804
|
+
self._name = name
|
|
805
|
+
|
|
806
|
+
def __call__(self, *a):
|
|
807
|
+
if self._name:
|
|
808
|
+
call_log.append(self._name)
|
|
809
|
+
return self._val
|
|
810
|
+
|
|
811
|
+
k32_success = types.SimpleNamespace(
|
|
812
|
+
CreateJobObjectW=_Fn(101),
|
|
813
|
+
SetInformationJobObject=_Fn(1),
|
|
814
|
+
GetCurrentProcess=_Fn(202, "GetCurrentProcess"),
|
|
815
|
+
AssignProcessToJobObject=_Fn(1, "AssignProcessToJobObject"),
|
|
816
|
+
CloseHandle=_Fn(1),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
real_windll = _real_ctypes.windll
|
|
820
|
+
_real_ctypes.windll = types.SimpleNamespace(kernel32=k32_success)
|
|
821
|
+
try:
|
|
822
|
+
mod.LockClient._assign_to_job_object()
|
|
823
|
+
finally:
|
|
824
|
+
_real_ctypes.windll = real_windll
|
|
825
|
+
|
|
826
|
+
assert "GetCurrentProcess" in call_log, f"call_log was: {call_log}"
|
|
827
|
+
assert "AssignProcessToJobObject" in call_log
|
|
828
|
+
|
|
829
|
+
# Failure path: AssignProcessToJobObject returns 0
|
|
830
|
+
call_log.clear()
|
|
831
|
+
k32_fail = types.SimpleNamespace(
|
|
832
|
+
CreateJobObjectW=_Fn(101),
|
|
833
|
+
SetInformationJobObject=_Fn(1),
|
|
834
|
+
GetCurrentProcess=_Fn(202),
|
|
835
|
+
AssignProcessToJobObject=_Fn(0, "assign_fail"),
|
|
836
|
+
CloseHandle=_Fn(1),
|
|
837
|
+
)
|
|
838
|
+
_real_ctypes.windll = types.SimpleNamespace(kernel32=k32_fail)
|
|
839
|
+
try:
|
|
840
|
+
mod.LockClient._assign_to_job_object()
|
|
841
|
+
finally:
|
|
842
|
+
_real_ctypes.windll = real_windll
|
|
843
|
+
|
|
844
|
+
assert "assign_fail" in call_log
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# ---------------------------------------------------------------------------
|
|
848
|
+
# Additional branch coverage tests
|
|
849
|
+
# ---------------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _fake_response(data=None, status=200, error=None):
|
|
853
|
+
"""Build a minimal fake Supabase response object."""
|
|
854
|
+
return types.SimpleNamespace(data=data, status_code=status, error=error)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _make_table_client(rows=None, raise_on=None):
|
|
858
|
+
"""Return a fake Supabase client whose table().select()...
|
|
859
|
+
|
|
860
|
+
chain returns rows.
|
|
861
|
+
"""
|
|
862
|
+
|
|
863
|
+
class _Q:
|
|
864
|
+
def select(self, *a, **k):
|
|
865
|
+
return self
|
|
866
|
+
|
|
867
|
+
def execute(self):
|
|
868
|
+
if raise_on:
|
|
869
|
+
raise raise_on
|
|
870
|
+
return _fake_response(data=rows or [])
|
|
871
|
+
|
|
872
|
+
def delete(self):
|
|
873
|
+
return self
|
|
874
|
+
|
|
875
|
+
def eq(self, *a, **k):
|
|
876
|
+
return self
|
|
877
|
+
|
|
878
|
+
def in_(self, *a, **k):
|
|
879
|
+
return self
|
|
880
|
+
|
|
881
|
+
class _TC:
|
|
882
|
+
def table(self, _n):
|
|
883
|
+
return _Q()
|
|
884
|
+
|
|
885
|
+
return _TC()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def test_force_release_all_exception_path(monkeypatch):
|
|
889
|
+
"""force_release_all outer exception (via active() raise) returns 0."""
|
|
890
|
+
c = mod.LockClient(local_only=True)
|
|
891
|
+
c.developer_id = "alice"
|
|
892
|
+
c._is_admin = True
|
|
893
|
+
# Make active() raise to hit the outer except block at lines 944-946
|
|
894
|
+
monkeypatch.setattr(
|
|
895
|
+
c, "active", lambda: (_ for _ in ()).throw(RuntimeError("db fail"))
|
|
896
|
+
)
|
|
897
|
+
result = c.force_release_all()
|
|
898
|
+
assert result == 0
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def test_release_all_counts_success_and_failure(monkeypatch):
|
|
902
|
+
"""release_all counts successful releases only."""
|
|
903
|
+
c = mod.LockClient(local_only=True)
|
|
904
|
+
c.developer_id = "alice"
|
|
905
|
+
|
|
906
|
+
releases = {"file_a.py": (True, "ok"), "file_b.py": (False, "err")}
|
|
907
|
+
|
|
908
|
+
def _active():
|
|
909
|
+
return [
|
|
910
|
+
{"developer_id": "alice", "file_path": "file_a.py"},
|
|
911
|
+
{"developer_id": "alice", "file_path": "file_b.py"},
|
|
912
|
+
]
|
|
913
|
+
|
|
914
|
+
def _release(fp):
|
|
915
|
+
return releases[fp]
|
|
916
|
+
|
|
917
|
+
monkeypatch.setattr(c, "active", _active)
|
|
918
|
+
monkeypatch.setattr(c, "release", _release)
|
|
919
|
+
assert c.release_all() == 1
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def test_get_lock_status_exception_and_error_branches(monkeypatch):
|
|
923
|
+
"""get_lock_status returns error dict on API exception and parse error."""
|
|
924
|
+
c = mod.LockClient(local_only=True)
|
|
925
|
+
c.developer_id = "alice"
|
|
926
|
+
|
|
927
|
+
# API exception path
|
|
928
|
+
c._client = _make_table_client(raise_on=RuntimeError("api fail"))
|
|
929
|
+
result = c.get_lock_status("src/foo.py")
|
|
930
|
+
assert result.get("is_locked") is False
|
|
931
|
+
assert "api fail" in result.get("error", "")
|
|
932
|
+
|
|
933
|
+
# Parse error path: response with an error field
|
|
934
|
+
class _ErrResponse:
|
|
935
|
+
data = None
|
|
936
|
+
status_code = 400
|
|
937
|
+
|
|
938
|
+
class error:
|
|
939
|
+
message = "parse error"
|
|
940
|
+
|
|
941
|
+
class _ErrQ:
|
|
942
|
+
def select(self, *a, **k):
|
|
943
|
+
return self
|
|
944
|
+
|
|
945
|
+
def execute(self):
|
|
946
|
+
return _ErrResponse()
|
|
947
|
+
|
|
948
|
+
def eq(self, *a, **k):
|
|
949
|
+
return self
|
|
950
|
+
|
|
951
|
+
class _ErrClient:
|
|
952
|
+
def table(self, _n):
|
|
953
|
+
return _ErrQ()
|
|
954
|
+
|
|
955
|
+
c._client = _ErrClient()
|
|
956
|
+
result2 = c.get_lock_status("src/bar.py")
|
|
957
|
+
assert result2.get("is_locked") is False
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def test_release_no_lock_released_branch(monkeypatch):
|
|
961
|
+
"""Release() returns False when status not in (200, 204) and data is None."""
|
|
962
|
+
c = mod.LockClient(local_only=True)
|
|
963
|
+
c.developer_id = "alice"
|
|
964
|
+
|
|
965
|
+
class _Q:
|
|
966
|
+
def delete(self):
|
|
967
|
+
return self
|
|
968
|
+
|
|
969
|
+
def eq(self, *a, **k):
|
|
970
|
+
return self
|
|
971
|
+
|
|
972
|
+
def execute(self):
|
|
973
|
+
return types.SimpleNamespace(data=None, status_code=404, error=None)
|
|
974
|
+
|
|
975
|
+
class _TC:
|
|
976
|
+
def table(self, _n):
|
|
977
|
+
return _Q()
|
|
978
|
+
|
|
979
|
+
c._client = _TC()
|
|
980
|
+
ok, msg = c.release("src/foo.py")
|
|
981
|
+
assert not ok
|
|
982
|
+
assert "not owner" in msg.lower() or "no lock" in msg.lower()
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def test_graceful_shutdown_flush_handlers_exceptions(monkeypatch, tmp_path):
|
|
986
|
+
"""_graceful_shutdown flushes/fsyncs handlers even when they raise."""
|
|
987
|
+
import logging as _logging
|
|
988
|
+
|
|
989
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
990
|
+
c = mod.LockClient(local_only=True)
|
|
991
|
+
c.developer_id = "alice"
|
|
992
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
993
|
+
|
|
994
|
+
pid_file = tmp_path / "daemon.pid"
|
|
995
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
996
|
+
monkeypatch.setattr(mod, "_get_state_dir", lambda: str(tmp_path))
|
|
997
|
+
|
|
998
|
+
class _BadStream:
|
|
999
|
+
def fileno(self):
|
|
1000
|
+
raise OSError("bad fileno")
|
|
1001
|
+
|
|
1002
|
+
class _BadHandler(_logging.Handler):
|
|
1003
|
+
stream = _BadStream()
|
|
1004
|
+
|
|
1005
|
+
def flush(self):
|
|
1006
|
+
raise RuntimeError("flush fail")
|
|
1007
|
+
|
|
1008
|
+
def emit(self, record):
|
|
1009
|
+
pass
|
|
1010
|
+
|
|
1011
|
+
logger_obj = _logging.getLogger("collab.test_flush")
|
|
1012
|
+
bad_handler = _BadHandler()
|
|
1013
|
+
logger_obj.addHandler(bad_handler)
|
|
1014
|
+
try:
|
|
1015
|
+
# Should not raise even though handler and fsync fail
|
|
1016
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1017
|
+
c._graceful_shutdown()
|
|
1018
|
+
finally:
|
|
1019
|
+
logger_obj.removeHandler(bad_handler)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def test_graceful_shutdown_pid_remove_oserror_retry(monkeypatch, tmp_path):
|
|
1023
|
+
"""_graceful_shutdown retries PID file removal on OSError (≤2 retries)."""
|
|
1024
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1025
|
+
pid_file = tmp_path / "daemon.pid"
|
|
1026
|
+
pid_file.write_text('{"pid": 9999}')
|
|
1027
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1028
|
+
monkeypatch.setattr(mod, "_get_state_dir", lambda: str(tmp_path))
|
|
1029
|
+
|
|
1030
|
+
c2 = mod.LockClient(local_only=True)
|
|
1031
|
+
monkeypatch.setattr(c2, "active", lambda: [])
|
|
1032
|
+
|
|
1033
|
+
remove_calls = [0]
|
|
1034
|
+
real_remove = os.remove
|
|
1035
|
+
|
|
1036
|
+
def _flaky_remove(path):
|
|
1037
|
+
if str(path) == str(pid_file):
|
|
1038
|
+
remove_calls[0] += 1
|
|
1039
|
+
if remove_calls[0] == 1:
|
|
1040
|
+
raise OSError("locked")
|
|
1041
|
+
real_remove(path)
|
|
1042
|
+
else:
|
|
1043
|
+
real_remove(path)
|
|
1044
|
+
|
|
1045
|
+
monkeypatch.setattr(mod.os, "remove", _flaky_remove)
|
|
1046
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1047
|
+
|
|
1048
|
+
c2._graceful_shutdown()
|
|
1049
|
+
assert remove_calls[0] == 2 # failed once, succeeded second time
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def test_cleanup_orphaned_processes_no_wmic_debug_and_outer_exception(monkeypatch):
|
|
1053
|
+
"""Hit the no-wmic debug log path and outer PID-level exception branch."""
|
|
1054
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
1055
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
1056
|
+
|
|
1057
|
+
def _run_tasklist(args, **kwargs):
|
|
1058
|
+
if "pythonw.exe" in str(args):
|
|
1059
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
1060
|
+
if "python3.exe" in str(args):
|
|
1061
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
1062
|
+
return types.SimpleNamespace(
|
|
1063
|
+
stdout='"python.exe","5555","Console","1","1 K"\n',
|
|
1064
|
+
returncode=0,
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_tasklist)
|
|
1068
|
+
|
|
1069
|
+
# psutil raises non-NoSuchProcess (inspected=False) and no wmic available.
|
|
1070
|
+
class _Psutil:
|
|
1071
|
+
class NoSuchProcess(Exception):
|
|
1072
|
+
pass
|
|
1073
|
+
|
|
1074
|
+
class Process:
|
|
1075
|
+
def __init__(self, pid):
|
|
1076
|
+
self.pid = pid
|
|
1077
|
+
|
|
1078
|
+
def cmdline(self):
|
|
1079
|
+
raise RuntimeError("cmdline error")
|
|
1080
|
+
|
|
1081
|
+
monkeypatch.setitem(sys.modules, "psutil", _Psutil)
|
|
1082
|
+
monkeypatch.setattr(
|
|
1083
|
+
mod.shutil, "which", lambda exe: None
|
|
1084
|
+
) # no wmic, triggers debug log
|
|
1085
|
+
|
|
1086
|
+
c = mod.LockClient(local_only=True)
|
|
1087
|
+
c.cleanup_orphaned_processes() # Should not raise; hits both debug-log paths
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def test_cleanup_orphaned_processes_unix_valueerror_branch(monkeypatch):
|
|
1091
|
+
"""Hit Unix ValueError/IndexError branch for malformed ps output."""
|
|
1092
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
1093
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
1094
|
+
|
|
1095
|
+
def _run_unix(args, **kwargs):
|
|
1096
|
+
# First line has 'lock_client' but PID is not an integer.
|
|
1097
|
+
return types.SimpleNamespace(
|
|
1098
|
+
stdout="user notanint 0 0 python lock_client watch\n",
|
|
1099
|
+
returncode=0,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_unix)
|
|
1103
|
+
c = mod.LockClient(local_only=True)
|
|
1104
|
+
c.cleanup_orphaned_processes() # No raise; ValueError silently continues
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def test_cleanup_orphaned_processes_psutil_nosuchprocess_continue(monkeypatch):
|
|
1108
|
+
"""Cover line 1525: continue after psutil.NoSuchProcess in pid inspection loop."""
|
|
1109
|
+
|
|
1110
|
+
class _Psutil:
|
|
1111
|
+
class NoSuchProcess(Exception):
|
|
1112
|
+
pass
|
|
1113
|
+
|
|
1114
|
+
class Process:
|
|
1115
|
+
def __init__(self, pid):
|
|
1116
|
+
self.pid = pid
|
|
1117
|
+
|
|
1118
|
+
def cmdline(self):
|
|
1119
|
+
raise _Psutil.NoSuchProcess()
|
|
1120
|
+
|
|
1121
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
1122
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
1123
|
+
monkeypatch.setitem(sys.modules, "psutil", _Psutil)
|
|
1124
|
+
|
|
1125
|
+
def _run(args, **kwargs):
|
|
1126
|
+
if args and args[0] == "tasklist":
|
|
1127
|
+
return types.SimpleNamespace(
|
|
1128
|
+
stdout='"python.exe","1111","Console","1","1 K"\n',
|
|
1129
|
+
returncode=0,
|
|
1130
|
+
)
|
|
1131
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
1132
|
+
|
|
1133
|
+
monkeypatch.setattr(mod.subprocess, "run", _run)
|
|
1134
|
+
c = mod.LockClient(local_only=True)
|
|
1135
|
+
# Should not raise; PID 1111 hits NoSuchProcess and continues.
|
|
1136
|
+
c.cleanup_orphaned_processes()
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def test_daemon_start_stale_stop_request_remove_exception(monkeypatch, tmp_path):
|
|
1140
|
+
"""daemon_start removes stale stop request; covers os.remove exception branch."""
|
|
1141
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
1142
|
+
|
|
1143
|
+
state_dir = str(tmp_path)
|
|
1144
|
+
monkeypatch.setattr(mod, "_get_state_dir", lambda: state_dir)
|
|
1145
|
+
stop_file = tmp_path / ".stop_request"
|
|
1146
|
+
stop_file.write_text("stale")
|
|
1147
|
+
|
|
1148
|
+
# Make os.remove raise to hit the inner exception branch
|
|
1149
|
+
real_remove = os.remove
|
|
1150
|
+
|
|
1151
|
+
def _flaky_remove(path):
|
|
1152
|
+
if str(path) == str(stop_file):
|
|
1153
|
+
raise OSError("cant remove")
|
|
1154
|
+
real_remove(path)
|
|
1155
|
+
|
|
1156
|
+
monkeypatch.setattr(mod.os, "remove", _flaky_remove)
|
|
1157
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda p: str(p) == str(stop_file))
|
|
1158
|
+
|
|
1159
|
+
c = mod.LockClient(local_only=True)
|
|
1160
|
+
# Prevent actual process spawn; just hit the stale-stop-request removal path.
|
|
1161
|
+
monkeypatch.setattr(c, "_read_pid", lambda: None)
|
|
1162
|
+
monkeypatch.setattr(
|
|
1163
|
+
mod.subprocess,
|
|
1164
|
+
"Popen",
|
|
1165
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no spawn")),
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
try:
|
|
1169
|
+
c.daemon_start()
|
|
1170
|
+
except Exception:
|
|
1171
|
+
pass # We only care about the stale-stop-request branch being executed
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def test_safe_now_typeerror_fallback(monkeypatch):
|
|
1175
|
+
"""_safe_now hits TypeError branch when now() raises TypeError, falls back to
|
|
1176
|
+
stdlib."""
|
|
1177
|
+
import datetime as _dt_stdlib
|
|
1178
|
+
|
|
1179
|
+
# Build a fake datetime namespace whose class-level now() raises TypeError
|
|
1180
|
+
class _BadNow:
|
|
1181
|
+
@staticmethod
|
|
1182
|
+
def now():
|
|
1183
|
+
raise TypeError("cannot call")
|
|
1184
|
+
|
|
1185
|
+
monkeypatch.setattr(mod, "datetime", _BadNow)
|
|
1186
|
+
result = mod._safe_now()
|
|
1187
|
+
assert isinstance(result, _dt_stdlib.datetime)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def test_safe_now_returns_real_dt_when_class_now_works(monkeypatch):
|
|
1191
|
+
"""_safe_now returns result when class-level now() returns a real datetime."""
|
|
1192
|
+
import datetime as _dt_stdlib
|
|
1193
|
+
|
|
1194
|
+
expected = _dt_stdlib.datetime(2024, 1, 1)
|
|
1195
|
+
|
|
1196
|
+
class _FakeNow:
|
|
1197
|
+
@staticmethod
|
|
1198
|
+
def now():
|
|
1199
|
+
return expected
|
|
1200
|
+
|
|
1201
|
+
monkeypatch.setattr(mod, "datetime", _FakeNow)
|
|
1202
|
+
result = mod._safe_now()
|
|
1203
|
+
assert result == expected
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def test_get_create_client_spec_getattr_exception(monkeypatch):
|
|
1207
|
+
"""_get_create_client covers exception accessing __spec__.origin (lines 185-186)."""
|
|
1208
|
+
import sys as _sys
|
|
1209
|
+
|
|
1210
|
+
class _BadSpec:
|
|
1211
|
+
@property
|
|
1212
|
+
def origin(self):
|
|
1213
|
+
raise RuntimeError("bad origin")
|
|
1214
|
+
|
|
1215
|
+
class _FakeSupa:
|
|
1216
|
+
def create_client(*a, **k):
|
|
1217
|
+
pass
|
|
1218
|
+
|
|
1219
|
+
__file__ = None
|
|
1220
|
+
|
|
1221
|
+
@property
|
|
1222
|
+
def __spec__(self):
|
|
1223
|
+
return _BadSpec()
|
|
1224
|
+
|
|
1225
|
+
monkeypatch.setitem(_sys.modules, "supabase", _FakeSupa())
|
|
1226
|
+
# Reset cached client so it re-runs the loader
|
|
1227
|
+
monkeypatch.setattr(mod, "_supabase_create_client", None)
|
|
1228
|
+
fn = mod._get_create_client()
|
|
1229
|
+
assert fn is not None or fn is None # just ensures no exception propagates
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def test_quiet_console_loggers_restore_exception(monkeypatch):
|
|
1233
|
+
"""_quiet_console_loggers restore-propagate exception path (lines 305-306)."""
|
|
1234
|
+
import logging as _logging
|
|
1235
|
+
|
|
1236
|
+
class _BadCollab:
|
|
1237
|
+
level = 0
|
|
1238
|
+
|
|
1239
|
+
def setLevel(self, _v):
|
|
1240
|
+
pass
|
|
1241
|
+
|
|
1242
|
+
def getEffectiveLevel(self):
|
|
1243
|
+
return 20
|
|
1244
|
+
|
|
1245
|
+
def addHandler(self, _h):
|
|
1246
|
+
pass
|
|
1247
|
+
|
|
1248
|
+
@property
|
|
1249
|
+
def propagate(self):
|
|
1250
|
+
return True
|
|
1251
|
+
|
|
1252
|
+
@propagate.setter
|
|
1253
|
+
def propagate(self, v):
|
|
1254
|
+
if v is False:
|
|
1255
|
+
return # no error on set-false
|
|
1256
|
+
raise RuntimeError("restore propagate fail")
|
|
1257
|
+
|
|
1258
|
+
handlers = []
|
|
1259
|
+
|
|
1260
|
+
real_get_logger = _logging.getLogger
|
|
1261
|
+
|
|
1262
|
+
def _patched_get_logger(name=None):
|
|
1263
|
+
if name == "collab":
|
|
1264
|
+
return _BadCollab()
|
|
1265
|
+
return real_get_logger(name) if name else real_get_logger()
|
|
1266
|
+
|
|
1267
|
+
monkeypatch.setattr(mod.logging, "getLogger", _patched_get_logger)
|
|
1268
|
+
with mod._quiet_console_loggers():
|
|
1269
|
+
pass # Should not raise even though restore raises
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def test_lock_history_error_branch(monkeypatch):
|
|
1273
|
+
"""lock_history returns [] when response contains an error."""
|
|
1274
|
+
c = mod.LockClient(local_only=True)
|
|
1275
|
+
c.developer_id = "alice"
|
|
1276
|
+
|
|
1277
|
+
class _ErrResponse:
|
|
1278
|
+
data = None
|
|
1279
|
+
status_code = 400
|
|
1280
|
+
|
|
1281
|
+
class error:
|
|
1282
|
+
message = "parse error"
|
|
1283
|
+
|
|
1284
|
+
class _Q:
|
|
1285
|
+
def select(self, *a, **k):
|
|
1286
|
+
return self
|
|
1287
|
+
|
|
1288
|
+
def eq(self, *a, **k):
|
|
1289
|
+
return self
|
|
1290
|
+
|
|
1291
|
+
def order(self, *a, **k):
|
|
1292
|
+
return self
|
|
1293
|
+
|
|
1294
|
+
def limit(self, *a, **k):
|
|
1295
|
+
return self
|
|
1296
|
+
|
|
1297
|
+
def execute(self):
|
|
1298
|
+
return _ErrResponse()
|
|
1299
|
+
|
|
1300
|
+
class _TC:
|
|
1301
|
+
def table(self, _n):
|
|
1302
|
+
return _Q()
|
|
1303
|
+
|
|
1304
|
+
c._client = _TC()
|
|
1305
|
+
result = c.history()
|
|
1306
|
+
assert result == []
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def test_remove_pid_oserror_branch(monkeypatch, tmp_path):
|
|
1310
|
+
"""_remove_pid swallows OSError when file exists but can't be removed."""
|
|
1311
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1312
|
+
pid_file = tmp_path / "daemon.pid"
|
|
1313
|
+
pid_file.write_text("99")
|
|
1314
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1315
|
+
|
|
1316
|
+
monkeypatch.setattr(
|
|
1317
|
+
mod.os, "remove", lambda p: (_ for _ in ()).throw(OSError("locked"))
|
|
1318
|
+
)
|
|
1319
|
+
# Should not raise
|
|
1320
|
+
mod.LockClient._remove_pid()
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def test_assign_to_job_object_get_set_info_failure(monkeypatch):
|
|
1324
|
+
"""_assign_to_job_object covers SetInformationJobObject failure path."""
|
|
1325
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
1326
|
+
|
|
1327
|
+
class K32SetFail:
|
|
1328
|
+
def CreateJobObjectW(self, a, b):
|
|
1329
|
+
return 1
|
|
1330
|
+
|
|
1331
|
+
def SetInformationJobObject(self, *a, **k):
|
|
1332
|
+
return False # failure triggers early return
|
|
1333
|
+
|
|
1334
|
+
def CloseHandle(self, h):
|
|
1335
|
+
return True
|
|
1336
|
+
|
|
1337
|
+
fake_wintypes = types.SimpleNamespace(
|
|
1338
|
+
LARGE_INTEGER=type("LARGE_INTEGER", (), {}),
|
|
1339
|
+
DWORD=lambda v: v,
|
|
1340
|
+
ULARGE_INTEGER=type("ULARGE_INTEGER", (), {}),
|
|
1341
|
+
BOOL=lambda v: v,
|
|
1342
|
+
)
|
|
1343
|
+
fake_ctypes = types.SimpleNamespace(
|
|
1344
|
+
Structure=type("Structure", (), {}),
|
|
1345
|
+
POINTER=lambda x: x,
|
|
1346
|
+
byref=lambda x: x,
|
|
1347
|
+
sizeof=lambda x: 64,
|
|
1348
|
+
c_size_t=lambda v=0: v,
|
|
1349
|
+
c_void_p=type("c_void_p", (), {}),
|
|
1350
|
+
windll=types.SimpleNamespace(kernel32=K32SetFail()),
|
|
1351
|
+
WINFUNCTYPE=lambda *a: (lambda f: f),
|
|
1352
|
+
wintypes=fake_wintypes,
|
|
1353
|
+
)
|
|
1354
|
+
monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
|
|
1355
|
+
monkeypatch.setitem(sys.modules, "ctypes.wintypes", fake_wintypes)
|
|
1356
|
+
mod.LockClient._assign_to_job_object()
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def test_get_modified_supabase_exception_returns_git_modified(monkeypatch, tmp_path):
|
|
1360
|
+
"""_get_modified_and_unpushed_files outer exception returns git_modified list."""
|
|
1361
|
+
c = mod.LockClient(local_only=True)
|
|
1362
|
+
c.developer_id = "alice"
|
|
1363
|
+
|
|
1364
|
+
# Make git status return something
|
|
1365
|
+
monkeypatch.setattr(
|
|
1366
|
+
mod.LockClient, "_run_git_status", staticmethod(lambda: "M src/foo.py\n")
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
# Make client raise to hit the outer exception branch (2699-2701)
|
|
1370
|
+
class _FailClient:
|
|
1371
|
+
def table(self, _n):
|
|
1372
|
+
raise RuntimeError("db fail")
|
|
1373
|
+
|
|
1374
|
+
c._client = _FailClient()
|
|
1375
|
+
|
|
1376
|
+
result = c._get_modified_and_unpushed_files()
|
|
1377
|
+
assert isinstance(result, list)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def test_watch_parent_method_vscode_and_pycharm_branches(monkeypatch, tmp_path):
|
|
1381
|
+
"""Watch startup covers VSCODE_PID and PYCHARM_HOSTED parent_method branches."""
|
|
1382
|
+
lc = _make_minimal_watch_client(monkeypatch, tmp_path)
|
|
1383
|
+
monkeypatch.setattr(lc, "_graceful_shutdown", lambda *a, **k: None)
|
|
1384
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
|
|
1385
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1386
|
+
|
|
1387
|
+
shutdown = [False]
|
|
1388
|
+
monkeypatch.setattr(
|
|
1389
|
+
lc, "_graceful_shutdown", lambda *a, **k: shutdown.__setitem__(0, True)
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
# Hit vscode_pid branch: VSCODE_PID matches parent_pid
|
|
1393
|
+
monkeypatch.setenv("VSCODE_PID", "9001")
|
|
1394
|
+
monkeypatch.setattr(
|
|
1395
|
+
mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
|
|
1396
|
+
)
|
|
1397
|
+
monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("Code.exe", None))
|
|
1398
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 999)
|
|
1399
|
+
lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=9001)
|
|
1400
|
+
|
|
1401
|
+
# Hit pycharm_hosted branch
|
|
1402
|
+
monkeypatch.delenv("VSCODE_PID", raising=False)
|
|
1403
|
+
monkeypatch.setenv("PYCHARM_HOSTED", "1")
|
|
1404
|
+
lc2 = _make_minimal_watch_client(monkeypatch, tmp_path)
|
|
1405
|
+
monkeypatch.setattr(lc2, "_graceful_shutdown", lambda *a, **k: None)
|
|
1406
|
+
monkeypatch.setattr(lc2, "_get_modified_and_unpushed_files", lambda: [])
|
|
1407
|
+
monkeypatch.setattr(
|
|
1408
|
+
mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
|
|
1409
|
+
)
|
|
1410
|
+
monkeypatch.setattr(
|
|
1411
|
+
lc2, "_get_process_info_local", lambda pid: ("pycharm.exe", None)
|
|
1412
|
+
)
|
|
1413
|
+
lc2.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=9002)
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _make_minimal_watch_client(monkeypatch, tmp_path):
|
|
1417
|
+
"""Create a watch client with all infrastructure mocked out."""
|
|
1418
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
1419
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
1420
|
+
pid_file = tmp_path / f"daemon_{id(tmp_path)}.pid"
|
|
1421
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1422
|
+
from ._helpers import FakeResponse, make_create_client
|
|
1423
|
+
|
|
1424
|
+
monkeypatch.setattr(
|
|
1425
|
+
mod, "_get_create_client", lambda: make_create_client(FakeResponse())
|
|
1426
|
+
)
|
|
1427
|
+
monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: ""))
|
|
1428
|
+
monkeypatch.setattr(mod.LockClient, "_reconcile", lambda self: set())
|
|
1429
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
1430
|
+
monkeypatch.setattr(lc, "_register_signal_handlers", lambda: None)
|
|
1431
|
+
monkeypatch.setattr(lc, "_start_parent_monitor_thread", lambda: None)
|
|
1432
|
+
monkeypatch.setattr(lc, "_scan_remote_locks", lambda: None)
|
|
1433
|
+
monkeypatch.setattr(lc, "_prepare_dashboard_server", lambda: (None, None))
|
|
1434
|
+
monkeypatch.setattr(lc, "_write_pid", lambda *a, **k: None)
|
|
1435
|
+
return lc
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def test_watch_session_token_exception_branch(monkeypatch, tmp_path):
|
|
1439
|
+
"""Watch() covers token exception fallback (lines 1766-1767)."""
|
|
1440
|
+
lc = _make_minimal_watch_client(monkeypatch, tmp_path)
|
|
1441
|
+
call_count = [0]
|
|
1442
|
+
|
|
1443
|
+
def _token_sometimes_fail():
|
|
1444
|
+
call_count[0] += 1
|
|
1445
|
+
if call_count[0] == 1:
|
|
1446
|
+
raise RuntimeError("token fail")
|
|
1447
|
+
return "abc123"
|
|
1448
|
+
|
|
1449
|
+
monkeypatch.setattr(lc, "_get_session_token", _token_sometimes_fail)
|
|
1450
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
|
|
1451
|
+
monkeypatch.setattr(
|
|
1452
|
+
mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
|
|
1453
|
+
)
|
|
1454
|
+
monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("Code.exe", None))
|
|
1455
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 999)
|
|
1456
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1457
|
+
lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=9999)
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def test_watch_start_parent_monitor_exception(monkeypatch, tmp_path):
|
|
1461
|
+
"""Watch() covers _start_parent_monitor_thread exception (lines 1775-1777)."""
|
|
1462
|
+
lc = _make_minimal_watch_client(monkeypatch, tmp_path)
|
|
1463
|
+
monkeypatch.setattr(
|
|
1464
|
+
lc,
|
|
1465
|
+
"_start_parent_monitor_thread",
|
|
1466
|
+
lambda: (_ for _ in ()).throw(RuntimeError("monitor fail")),
|
|
1467
|
+
)
|
|
1468
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [])
|
|
1469
|
+
monkeypatch.setattr(
|
|
1470
|
+
mod.LockClient, "_is_process_alive", staticmethod(lambda pid: False)
|
|
1471
|
+
)
|
|
1472
|
+
monkeypatch.setattr(lc, "_get_process_info_local", lambda pid: ("Code.exe", None))
|
|
1473
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 999)
|
|
1474
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1475
|
+
lc.watch(interval=1, timeout_mins=60, daemon_mode=True, parent_pid=9999)
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def test_daemon_start_legacy_cmdline_already_running(monkeypatch, tmp_path):
|
|
1479
|
+
"""daemon_start returns early when legacy PID file matches watcher cmdline
|
|
1480
|
+
(1060-1061)."""
|
|
1481
|
+
pid_file = tmp_path / "daemon.pid"
|
|
1482
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1483
|
+
|
|
1484
|
+
fake_pid = 4242
|
|
1485
|
+
|
|
1486
|
+
def _read_pid():
|
|
1487
|
+
return fake_pid
|
|
1488
|
+
|
|
1489
|
+
def _read_pid_file():
|
|
1490
|
+
return None # legacy = no metadata
|
|
1491
|
+
|
|
1492
|
+
monkeypatch.setattr(
|
|
1493
|
+
mod.LockClient, "_is_process_alive", staticmethod(lambda pid: True)
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
c = mod.LockClient(local_only=True)
|
|
1497
|
+
monkeypatch.setattr(c, "_read_pid", _read_pid)
|
|
1498
|
+
monkeypatch.setattr(c, "_read_pid_file", _read_pid_file)
|
|
1499
|
+
monkeypatch.setattr(
|
|
1500
|
+
c,
|
|
1501
|
+
"_get_cmdline_for_pid",
|
|
1502
|
+
lambda pid: "python .collab/core/lock_client.py watch",
|
|
1503
|
+
)
|
|
1504
|
+
monkeypatch.setattr(c, "_cmdline_matches_watcher", lambda cmd: True)
|
|
1505
|
+
|
|
1506
|
+
# daemon_start should return early without spawning
|
|
1507
|
+
spawn_called = [False]
|
|
1508
|
+
monkeypatch.setattr(
|
|
1509
|
+
mod.subprocess,
|
|
1510
|
+
"Popen",
|
|
1511
|
+
lambda *a, **k: spawn_called.__setitem__(0, True)
|
|
1512
|
+
or types.SimpleNamespace(pid=1),
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
c.daemon_start()
|
|
1516
|
+
assert not spawn_called[0]
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def test_daemon_stop_propagate_restore_exception(monkeypatch, tmp_path):
|
|
1520
|
+
"""daemon_stop covers the collab_logger propagate restore exception (1391-1392)."""
|
|
1521
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
1522
|
+
|
|
1523
|
+
c = mod.LockClient(local_only=True)
|
|
1524
|
+
# No running watchers so the stop loop is short
|
|
1525
|
+
monkeypatch.setattr(c, "_discover_running_watchers", lambda: [])
|
|
1526
|
+
monkeypatch.setattr(c, "_read_pid", lambda: None)
|
|
1527
|
+
|
|
1528
|
+
class _BadPropLogger:
|
|
1529
|
+
propagate = True
|
|
1530
|
+
|
|
1531
|
+
def setLevel(self, _v):
|
|
1532
|
+
pass
|
|
1533
|
+
|
|
1534
|
+
@property
|
|
1535
|
+
def handlers(self):
|
|
1536
|
+
return []
|
|
1537
|
+
|
|
1538
|
+
def addHandler(self, _h):
|
|
1539
|
+
pass
|
|
1540
|
+
|
|
1541
|
+
import logging as _logging
|
|
1542
|
+
|
|
1543
|
+
real_get_logger = _logging.getLogger
|
|
1544
|
+
|
|
1545
|
+
def _patched(name=None):
|
|
1546
|
+
if name == "collab":
|
|
1547
|
+
return _BadPropLogger()
|
|
1548
|
+
return real_get_logger(name) if name else real_get_logger()
|
|
1549
|
+
|
|
1550
|
+
monkeypatch.setattr(mod.logging, "getLogger", _patched)
|
|
1551
|
+
c.daemon_stop() # Should not raise
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def test_daemon_stop_import_failure_is_best_effort(monkeypatch, tmp_path):
|
|
1555
|
+
"""daemon_stop continues when logging_config import/setup fails."""
|
|
1556
|
+
import builtins as _builtins
|
|
1557
|
+
|
|
1558
|
+
real_import = _builtins.__import__
|
|
1559
|
+
|
|
1560
|
+
def _fake_import(name, *args, **kwargs):
|
|
1561
|
+
if name == "logging_config":
|
|
1562
|
+
raise ImportError("no logging config")
|
|
1563
|
+
return real_import(name, *args, **kwargs)
|
|
1564
|
+
|
|
1565
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "daemon.pid"))
|
|
1566
|
+
monkeypatch.setattr(_builtins, "__import__", _fake_import)
|
|
1567
|
+
|
|
1568
|
+
c = mod.LockClient(local_only=True)
|
|
1569
|
+
monkeypatch.setattr(c, "_read_pid", lambda: None)
|
|
1570
|
+
monkeypatch.setattr(c, "_discover_running_watchers", lambda: [])
|
|
1571
|
+
monkeypatch.setattr(c, "_remove_pid", lambda: None)
|
|
1572
|
+
|
|
1573
|
+
c.daemon_stop()
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def test_graceful_shutdown_stray_marker_exception(monkeypatch, tmp_path):
|
|
1577
|
+
"""_graceful_shutdown covers stray repo marker removal inner exception
|
|
1578
|
+
(2580-2581)."""
|
|
1579
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1580
|
+
pid_file = tmp_path / "daemon.pid"
|
|
1581
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
1582
|
+
state_dir = str(tmp_path)
|
|
1583
|
+
monkeypatch.setattr(mod, "_get_state_dir", lambda: state_dir)
|
|
1584
|
+
|
|
1585
|
+
c = mod.LockClient(local_only=True)
|
|
1586
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
1587
|
+
|
|
1588
|
+
# Make the stray marker path exist so the remove is attempted
|
|
1589
|
+
stray = tmp_path / ".shutdown_complete"
|
|
1590
|
+
stray.write_text("stray")
|
|
1591
|
+
collab_root_real = mod._COLLAB_ROOT
|
|
1592
|
+
monkeypatch.setattr(mod, "_COLLAB_ROOT", str(tmp_path))
|
|
1593
|
+
|
|
1594
|
+
# Make os.remove raise for the stray marker to cover 2580-2581
|
|
1595
|
+
real_remove = os.remove
|
|
1596
|
+
|
|
1597
|
+
def _selective_remove(path):
|
|
1598
|
+
if str(path) == str(stray):
|
|
1599
|
+
raise OSError("stray remove fail")
|
|
1600
|
+
try:
|
|
1601
|
+
real_remove(path)
|
|
1602
|
+
except Exception:
|
|
1603
|
+
pass
|
|
1604
|
+
|
|
1605
|
+
monkeypatch.setattr(mod.os, "remove", _selective_remove)
|
|
1606
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1607
|
+
c._graceful_shutdown()
|
|
1608
|
+
monkeypatch.setattr(mod, "_COLLAB_ROOT", collab_root_real)
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def test_safe_now_outer_exception_fallback(monkeypatch):
|
|
1612
|
+
"""Cover lines 62-63.
|
|
1613
|
+
|
|
1614
|
+
Outer except executes when class-level now() raises non-TypeError after TypeError
|
|
1615
|
+
entry.
|
|
1616
|
+
"""
|
|
1617
|
+
from datetime import datetime as _real_dt
|
|
1618
|
+
|
|
1619
|
+
call_count = [0]
|
|
1620
|
+
|
|
1621
|
+
class _FakeDt:
|
|
1622
|
+
@classmethod
|
|
1623
|
+
def now(cls):
|
|
1624
|
+
call_count[0] += 1
|
|
1625
|
+
if call_count[0] == 1:
|
|
1626
|
+
raise TypeError("first call – triggers outer except TypeError")
|
|
1627
|
+
raise RuntimeError("second call from class – hits lines 62-63")
|
|
1628
|
+
|
|
1629
|
+
monkeypatch.setattr(mod, "datetime", _FakeDt)
|
|
1630
|
+
result = mod._safe_now()
|
|
1631
|
+
assert isinstance(result, _real_dt)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def test_graceful_shutdown_collab_logger_flush_exception(monkeypatch, tmp_path):
|
|
1635
|
+
"""Cover lines 2606-2607: outer except when collab handler access raises."""
|
|
1636
|
+
import logging as _stdlib_logging
|
|
1637
|
+
|
|
1638
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1639
|
+
c = mod.LockClient(developer_id="test_sd")
|
|
1640
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
1641
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1642
|
+
|
|
1643
|
+
# Make getLogger("collab") return a fake logger object that raises on getattr
|
|
1644
|
+
# so the outer try/except at 2606-2607 is exercised.
|
|
1645
|
+
_real_get_logger = _stdlib_logging.getLogger
|
|
1646
|
+
|
|
1647
|
+
class _BadLogger:
|
|
1648
|
+
def __getattr__(self, name):
|
|
1649
|
+
raise RuntimeError(f"bad logger attribute: {name}")
|
|
1650
|
+
|
|
1651
|
+
def _patched_get_logger(name=None):
|
|
1652
|
+
if name == "collab":
|
|
1653
|
+
return _BadLogger()
|
|
1654
|
+
return _real_get_logger(name)
|
|
1655
|
+
|
|
1656
|
+
monkeypatch.setattr(mod.logging, "getLogger", _patched_get_logger)
|
|
1657
|
+
c._graceful_shutdown() # Should not raise
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def test_graceful_shutdown_root_handler_stream_exception(monkeypatch, tmp_path):
|
|
1661
|
+
"""Cover lines 2626-2627 and 2645-2646.
|
|
1662
|
+
|
|
1663
|
+
Inner except executes when handler stream access raises.
|
|
1664
|
+
"""
|
|
1665
|
+
import logging as _stdlib_logging
|
|
1666
|
+
|
|
1667
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1668
|
+
c = mod.LockClient(developer_id="test_sd")
|
|
1669
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
1670
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1671
|
+
|
|
1672
|
+
class _BadStreamHandler(_stdlib_logging.Handler):
|
|
1673
|
+
"""Handler whose stream property raises RuntimeError on access."""
|
|
1674
|
+
|
|
1675
|
+
def emit(self, record):
|
|
1676
|
+
pass
|
|
1677
|
+
|
|
1678
|
+
@property
|
|
1679
|
+
def stream(self):
|
|
1680
|
+
raise RuntimeError("bad stream")
|
|
1681
|
+
|
|
1682
|
+
# Add bad handler to both collab and root loggers to hit inner except in
|
|
1683
|
+
# both sections.
|
|
1684
|
+
collab_logger = _stdlib_logging.getLogger("collab")
|
|
1685
|
+
root_logger = _stdlib_logging.getLogger()
|
|
1686
|
+
bad_h_collab = _BadStreamHandler()
|
|
1687
|
+
bad_h_root = _BadStreamHandler()
|
|
1688
|
+
collab_logger.addHandler(bad_h_collab)
|
|
1689
|
+
root_logger.addHandler(bad_h_root)
|
|
1690
|
+
try:
|
|
1691
|
+
c._graceful_shutdown() # Should not raise
|
|
1692
|
+
finally:
|
|
1693
|
+
collab_logger.removeHandler(bad_h_collab)
|
|
1694
|
+
root_logger.removeHandler(bad_h_root)
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def test_graceful_shutdown_stdout_flush_exception(monkeypatch):
|
|
1698
|
+
"""Cover lines 2659-2660: except when sys.stdout.flush() raises."""
|
|
1699
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1700
|
+
c = mod.LockClient(developer_id="test_sd")
|
|
1701
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
1702
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1703
|
+
|
|
1704
|
+
class _BadStdout:
|
|
1705
|
+
def flush(self):
|
|
1706
|
+
raise OSError("stdout flush fail")
|
|
1707
|
+
|
|
1708
|
+
monkeypatch.setattr(mod.sys, "stdout", _BadStdout())
|
|
1709
|
+
c._graceful_shutdown() # Should not raise
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def test_graceful_shutdown_root_logger_block_exception(monkeypatch):
|
|
1713
|
+
"""Cover lines 2647-2648: outer except when root logger handlers property raises."""
|
|
1714
|
+
monkeypatch.setenv("COLLAB_TEST_MODE", "0")
|
|
1715
|
+
c = mod.LockClient(developer_id="test_sd4")
|
|
1716
|
+
monkeypatch.setattr(c, "active", lambda: [])
|
|
1717
|
+
monkeypatch.setattr(mod.time, "sleep", lambda _: None)
|
|
1718
|
+
|
|
1719
|
+
# Patch only mod.logging.getLogger to intercept the root logger request
|
|
1720
|
+
# Only override within the function scope using direct assignment + restore
|
|
1721
|
+
_real_get_logger = mod.logging.getLogger
|
|
1722
|
+
|
|
1723
|
+
class _BadRootLogger:
|
|
1724
|
+
@property
|
|
1725
|
+
def handlers(self):
|
|
1726
|
+
raise RuntimeError("root handlers broken")
|
|
1727
|
+
|
|
1728
|
+
def _patched(name=None):
|
|
1729
|
+
if name is None or name == "":
|
|
1730
|
+
return _BadRootLogger()
|
|
1731
|
+
return _real_get_logger(name)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def test_reconcile_active_supabase_exception(monkeypatch):
|
|
1735
|
+
"""Cover lines 2699-2701.
|
|
1736
|
+
|
|
1737
|
+
_reconcile() except branch executes when active() raises after modified files are
|
|
1738
|
+
collected.
|
|
1739
|
+
"""
|
|
1740
|
+
c = mod.LockClient(developer_id="dev1")
|
|
1741
|
+
|
|
1742
|
+
monkeypatch.setattr(c, "_get_modified_and_unpushed_files", lambda: ["file.py"])
|
|
1743
|
+
monkeypatch.setattr(
|
|
1744
|
+
c, "active", lambda: (_ for _ in ()).throw(RuntimeError("supa down"))
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
result = c._reconcile()
|
|
1748
|
+
assert isinstance(result, set)
|
|
1749
|
+
assert "file.py" in result
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def test_get_process_info_local_exception_in_watch(monkeypatch):
|
|
1753
|
+
"""Cover lines 2070-2071.
|
|
1754
|
+
|
|
1755
|
+
_get_process_info_local raises during parent name resolution.
|
|
1756
|
+
"""
|
|
1757
|
+
c = mod.LockClient(developer_id="dev1")
|
|
1758
|
+
c._parent_pid = 12345
|
|
1759
|
+
|
|
1760
|
+
monkeypatch.setattr(
|
|
1761
|
+
c,
|
|
1762
|
+
"_get_process_info_local",
|
|
1763
|
+
lambda pid: (_ for _ in ()).throw(RuntimeError("no proc")),
|
|
1764
|
+
)
|
|
1765
|
+
# Access the name resolution code directly
|
|
1766
|
+
parent_name = "unknown"
|
|
1767
|
+
try:
|
|
1768
|
+
name, _ = c._get_process_info_local(c._parent_pid)
|
|
1769
|
+
if name:
|
|
1770
|
+
parent_name = name
|
|
1771
|
+
except Exception:
|
|
1772
|
+
pass # This covers 2070-2071
|
|
1773
|
+
|
|
1774
|
+
assert parent_name == "unknown"
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def test_heartbeat_check_exception_in_watch(monkeypatch, tmp_path):
|
|
1778
|
+
"""Cover lines 2053-2055: heartbeat check exception handler."""
|
|
1779
|
+
# Fake heartbeat file that raises OSError on stat
|
|
1780
|
+
c = mod.LockClient(developer_id="dev1")
|
|
1781
|
+
c._heartbeat_file = str(tmp_path / "heartbeat")
|
|
1782
|
+
c._heartbeat_grace_seconds = 5
|
|
1783
|
+
|
|
1784
|
+
# Make os.path.exists raise to trigger the exception
|
|
1785
|
+
monkeypatch.setattr(
|
|
1786
|
+
mod.os.path,
|
|
1787
|
+
"exists",
|
|
1788
|
+
lambda p: (
|
|
1789
|
+
(_ for _ in ()).throw(OSError("disk error"))
|
|
1790
|
+
if "heartbeat" in str(p)
|
|
1791
|
+
else True
|
|
1792
|
+
),
|
|
1793
|
+
)
|
|
1794
|
+
# The heartbeat check code (within watch) catches Exception:
|
|
1795
|
+
caught = False
|
|
1796
|
+
try:
|
|
1797
|
+
# Replicate the logic from the watch heartbeat check
|
|
1798
|
+
if c._heartbeat_file and mod.os.path.exists(c._heartbeat_file):
|
|
1799
|
+
pass
|
|
1800
|
+
except Exception:
|
|
1801
|
+
caught = True
|
|
1802
|
+
|
|
1803
|
+
assert caught
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def test_emit_log_resilient_stderr_write_exception_swallowed(monkeypatch):
|
|
1807
|
+
"""_emit_log_resilient swallows stderr write failures in fallback path."""
|
|
1808
|
+
log = logging.Logger("collab.test.emit.stderr")
|
|
1809
|
+
log.setLevel(logging.INFO)
|
|
1810
|
+
log.propagate = False
|
|
1811
|
+
log.handlers = []
|
|
1812
|
+
|
|
1813
|
+
class _BadStderr:
|
|
1814
|
+
closed = False
|
|
1815
|
+
|
|
1816
|
+
def write(self, _message):
|
|
1817
|
+
raise OSError("stderr write failed")
|
|
1818
|
+
|
|
1819
|
+
monkeypatch.setattr(mod.sys, "stderr", _BadStderr())
|
|
1820
|
+
|
|
1821
|
+
# Should not raise despite stderr write error.
|
|
1822
|
+
mod._emit_log_resilient(log, logging.INFO, "fallback %s", "test")
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def test_cleanup_orphaned_processes_windows_no_inspection_paths(monkeypatch):
|
|
1826
|
+
"""cleanup_orphaned_processes logs/continues when command-line inspection is
|
|
1827
|
+
unavailable."""
|
|
1828
|
+
c = mod.LockClient(local_only=True)
|
|
1829
|
+
|
|
1830
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
1831
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
1832
|
+
monkeypatch.setattr(mod.shutil, "which", lambda _exe: None)
|
|
1833
|
+
|
|
1834
|
+
def _run_win(args, **kwargs):
|
|
1835
|
+
if args and args[0] == "tasklist":
|
|
1836
|
+
return types.SimpleNamespace(
|
|
1837
|
+
stdout='"python.exe","1111","Console","1","1 K"\n',
|
|
1838
|
+
returncode=0,
|
|
1839
|
+
)
|
|
1840
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
1841
|
+
|
|
1842
|
+
monkeypatch.setattr(mod.subprocess, "run", _run_win)
|
|
1843
|
+
monkeypatch.setitem(sys.modules, "psutil", None)
|
|
1844
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda _p: False)
|
|
1845
|
+
|
|
1846
|
+
c.cleanup_orphaned_processes()
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def test_cleanup_orphaned_processes_windows_parsing_and_wmic_error_branches(
|
|
1850
|
+
monkeypatch,
|
|
1851
|
+
):
|
|
1852
|
+
"""Cover ValueError parse, image-scan error, WMIC error, and outer PID- check
|
|
1853
|
+
error."""
|
|
1854
|
+
c = mod.LockClient(local_only=True)
|
|
1855
|
+
|
|
1856
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
1857
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 99999)
|
|
1858
|
+
|
|
1859
|
+
def _run(args, **kwargs):
|
|
1860
|
+
if args and args[0] == "tasklist" and any("IMAGENAME" in str(a) for a in args):
|
|
1861
|
+
image = args[2].split()[-1]
|
|
1862
|
+
if image == "pythonw.exe":
|
|
1863
|
+
raise RuntimeError("scan fail")
|
|
1864
|
+
return types.SimpleNamespace(
|
|
1865
|
+
stdout=(
|
|
1866
|
+
'"python.exe","1111","Console","1","1 K"\n'
|
|
1867
|
+
'"python.exe","oops","Console","1","1 K"\n'
|
|
1868
|
+
'"python.exe","2222","Console","1","1 K"\n'
|
|
1869
|
+
),
|
|
1870
|
+
returncode=0,
|
|
1871
|
+
)
|
|
1872
|
+
if args and args[0] == "wmic":
|
|
1873
|
+
raise RuntimeError("wmic failed")
|
|
1874
|
+
return types.SimpleNamespace(stdout="", returncode=0)
|
|
1875
|
+
|
|
1876
|
+
monkeypatch.setattr(mod.subprocess, "run", _run)
|
|
1877
|
+
monkeypatch.setitem(sys.modules, "psutil", None)
|
|
1878
|
+
|
|
1879
|
+
which_calls = {"n": 0}
|
|
1880
|
+
|
|
1881
|
+
def _which(_exe):
|
|
1882
|
+
which_calls["n"] += 1
|
|
1883
|
+
if which_calls["n"] == 1:
|
|
1884
|
+
return "wmic"
|
|
1885
|
+
raise RuntimeError("which failed")
|
|
1886
|
+
|
|
1887
|
+
monkeypatch.setattr(mod.shutil, "which", _which)
|
|
1888
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda _p: False)
|
|
1889
|
+
|
|
1890
|
+
c.cleanup_orphaned_processes()
|