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,684 @@
|
|
|
1
|
+
"""PID and process helper tests for live_locks_watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import types
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from ._helpers import load_watcher_module
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific process helper")
|
|
18
|
+
def test_get_cmdline_for_pid_local_wmic_and_powershell(monkeypatch):
|
|
19
|
+
mod = load_watcher_module()
|
|
20
|
+
if "psutil" in sys.modules:
|
|
21
|
+
del sys.modules["psutil"]
|
|
22
|
+
|
|
23
|
+
def fake_check_output(cmd, stderr=None, text=None, creationflags=None):
|
|
24
|
+
if cmd[0] == "wmic":
|
|
25
|
+
return "CommandLine\npython watch.exe\n"
|
|
26
|
+
if cmd[0] == "powershell":
|
|
27
|
+
return "python powershell_watch.exe"
|
|
28
|
+
raise RuntimeError("unexpected")
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr(subprocess, "check_output", fake_check_output)
|
|
31
|
+
got = mod._get_cmdline_for_pid_local(1234)
|
|
32
|
+
assert "watch.exe" in got or "powershell_watch" in got
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_write_pid_file_and_get_developer_and_branch(monkeypatch, tmp_path):
|
|
36
|
+
mod = load_watcher_module()
|
|
37
|
+
pid_file = tmp_path / ".daemon.pid"
|
|
38
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
39
|
+
|
|
40
|
+
mod._write_pid_file(4242)
|
|
41
|
+
assert pid_file.exists()
|
|
42
|
+
raw = pid_file.read_text(encoding="utf-8")
|
|
43
|
+
obj = __import__("json").loads(raw)
|
|
44
|
+
assert obj["pid"] == 4242
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr(subprocess, "check_output", lambda *a, **k: b"devname\n")
|
|
47
|
+
dev = mod._get_developer_id()
|
|
48
|
+
assert isinstance(dev, str)
|
|
49
|
+
branch = mod._get_current_branch()
|
|
50
|
+
assert isinstance(branch, str)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_is_process_alive_current_pid():
|
|
54
|
+
mod = load_watcher_module()
|
|
55
|
+
|
|
56
|
+
result = mod._is_process_alive(os.getpid())
|
|
57
|
+
assert result is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_is_process_alive_nonexistent_pid():
|
|
61
|
+
mod = load_watcher_module()
|
|
62
|
+
# Use a very large PID that is unlikely to exist
|
|
63
|
+
assert mod._is_process_alive(99999999) is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_is_process_alive_fallback_without_psutil(monkeypatch):
|
|
67
|
+
mod = load_watcher_module()
|
|
68
|
+
import builtins as _builtins
|
|
69
|
+
|
|
70
|
+
real_import = _builtins.__import__
|
|
71
|
+
|
|
72
|
+
def fake_import(name, *a, **k):
|
|
73
|
+
if name == "psutil":
|
|
74
|
+
raise ImportError("no psutil")
|
|
75
|
+
return real_import(name, *a, **k)
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(_builtins, "__import__", fake_import)
|
|
78
|
+
|
|
79
|
+
def fake_check_output(*a, **k):
|
|
80
|
+
raise Exception("tasklist failed")
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr("subprocess.check_output", fake_check_output)
|
|
83
|
+
|
|
84
|
+
# Should return False when both psutil unavailable and tasklist fails
|
|
85
|
+
assert mod._is_process_alive(999999) is False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_live_locks_watcher_get_parent_ide_pid_traversal_gap(monkeypatch):
|
|
89
|
+
mod = load_watcher_module()
|
|
90
|
+
"""Cover IDE ancestor search fallbacks."""
|
|
91
|
+
tree = {
|
|
92
|
+
100: ("python.exe", 99),
|
|
93
|
+
99: ("language_server_windows_x64.exe", 98),
|
|
94
|
+
98: ("Antigravity.exe", 1),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def mock_info_local(p):
|
|
98
|
+
return tree.get(p, (None, None))
|
|
99
|
+
|
|
100
|
+
monkeypatch.setattr(mod, "_get_process_info_local", mock_info_local)
|
|
101
|
+
|
|
102
|
+
# Use monkeypatch for getpid for the watcher module's os reference
|
|
103
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 100)
|
|
104
|
+
|
|
105
|
+
# Path A: Directly ties to IDE
|
|
106
|
+
assert mod._get_parent_ide_pid_local() == 98
|
|
107
|
+
|
|
108
|
+
# Path: getppid fallback
|
|
109
|
+
monkeypatch.setattr(mod, "_get_process_info_local", lambda p: (None, None))
|
|
110
|
+
monkeypatch.setattr(mod.os.path, "exists", lambda x: False)
|
|
111
|
+
monkeypatch.delenv("VSCODE_PID", raising=False)
|
|
112
|
+
monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
|
|
113
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 777)
|
|
114
|
+
assert mod._get_parent_ide_pid_local() == 777
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_live_locks_watcher_process_helpers_error_gaps(monkeypatch):
|
|
118
|
+
mod = load_watcher_module()
|
|
119
|
+
"""Cover various exception branches in process helpers."""
|
|
120
|
+
# _get_process_info_local exception
|
|
121
|
+
with mock.patch("subprocess.check_output", side_effect=Exception("cmd fail")):
|
|
122
|
+
assert mod._get_process_info_local(123) == (None, None)
|
|
123
|
+
|
|
124
|
+
# Simulate psutil failures
|
|
125
|
+
mock_psutil = mock.MagicMock()
|
|
126
|
+
mock_psutil.pid_exists.return_value = False
|
|
127
|
+
mock_psutil.Process.side_effect = Exception("psutil fail")
|
|
128
|
+
|
|
129
|
+
with mock.patch.dict(sys.modules, {"psutil": mock_psutil}):
|
|
130
|
+
assert mod._is_process_alive(123) is False
|
|
131
|
+
|
|
132
|
+
# _get_cmdline_for_pid_local error path
|
|
133
|
+
with mock.patch.dict(sys.modules, {"psutil": mock_psutil}):
|
|
134
|
+
mock_psutil.Process.side_effect = Exception("psutil fail")
|
|
135
|
+
assert mod._get_cmdline_for_pid_local(123) is None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---- Auto-migrated from migrated_remaining ----
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_get_current_branch_success(monkeypatch):
|
|
142
|
+
"""Test getting current branch on the current platform."""
|
|
143
|
+
mod = load_watcher_module()
|
|
144
|
+
|
|
145
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
146
|
+
if "branch" in cmd and "--show-current" in cmd:
|
|
147
|
+
return b"feature/test-branch\n"
|
|
148
|
+
raise subprocess.CalledProcessError(1, cmd)
|
|
149
|
+
|
|
150
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
151
|
+
result = mod._get_current_branch()
|
|
152
|
+
assert result == "feature/test-branch"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_get_current_branch_error(monkeypatch):
|
|
156
|
+
"""Test getting current branch returns 'unknown' on error (lines 112-113)."""
|
|
157
|
+
mod = load_watcher_module()
|
|
158
|
+
|
|
159
|
+
def mock_check_output(cmd, *args, **kwargs):
|
|
160
|
+
raise subprocess.CalledProcessError(128, cmd)
|
|
161
|
+
|
|
162
|
+
monkeypatch.setattr(subprocess, "check_output", mock_check_output)
|
|
163
|
+
result = mod._get_current_branch()
|
|
164
|
+
assert result == "unknown"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ============================================================================
|
|
168
|
+
# _is_process_alive Tests (lines 158, 170-176)
|
|
169
|
+
# ============================================================================
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_shorten_process_label_and_cmdline_match_moved():
|
|
173
|
+
mod = load_watcher_module()
|
|
174
|
+
long = "/usr/bin/python /very/long/path/to/some/script.py arg1 arg2 arg3 arg4 arg5"
|
|
175
|
+
s = mod._shorten_process_label(long, max_tokens=4, max_len=50)
|
|
176
|
+
assert s is not None
|
|
177
|
+
assert "python" in s
|
|
178
|
+
assert mod._cmdline_matches_watcher_local(
|
|
179
|
+
"python .collab/pycharm/live_locks_mod.py"
|
|
180
|
+
)
|
|
181
|
+
assert not mod._cmdline_matches_watcher_local("C:/Windows/not_mod.exe")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_should_ignore_and_cmdline_helpers_migrated():
|
|
185
|
+
mod = load_watcher_module()
|
|
186
|
+
assert mod._should_ignore_path(".git/objects/abc") is True
|
|
187
|
+
assert mod._should_ignore_path("src/app.py") is False
|
|
188
|
+
|
|
189
|
+
assert mod._cmdline_matches_watcher_local("python live_locks_watcher") is True
|
|
190
|
+
assert mod._cmdline_matches_watcher_local(None) is False
|
|
191
|
+
|
|
192
|
+
shortened = mod._shorten_process_label(
|
|
193
|
+
"C:/some/very/long/path/python.exe script.py token1 token2 token3 token4 token5"
|
|
194
|
+
)
|
|
195
|
+
assert shortened is not None
|
|
196
|
+
assert ("..." in shortened) or (len(shortened) <= 80)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_write_and_existing_watcher_running_migrated(monkeypatch, tmp_path):
|
|
200
|
+
mod = load_watcher_module()
|
|
201
|
+
pid_file = tmp_path / f"pytest_collab_{os.getpid()}.daemon.pid"
|
|
202
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
203
|
+
|
|
204
|
+
meta = {
|
|
205
|
+
"pid": os.getpid(),
|
|
206
|
+
"cmdline": "python live_locks_watcher",
|
|
207
|
+
"entrypoint": "pycharm-watcher",
|
|
208
|
+
}
|
|
209
|
+
with open(mod.PID_FILE, "w", encoding="utf-8") as fh:
|
|
210
|
+
json.dump(meta, fh)
|
|
211
|
+
|
|
212
|
+
monkeypatch.setattr(
|
|
213
|
+
mod, "_get_cmdline_for_pid_local", lambda pid: "python live_locks_watcher"
|
|
214
|
+
)
|
|
215
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: True)
|
|
216
|
+
|
|
217
|
+
running, pid, cmdline, entry = mod._existing_watcher_running()
|
|
218
|
+
assert running is True
|
|
219
|
+
assert pid == os.getpid()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_write_pid_file_and_read_migrated(monkeypatch, tmp_path):
|
|
223
|
+
mod = load_watcher_module()
|
|
224
|
+
monkeypatch.setattr(mod, "PID_FILE", str(tmp_path / "pidfile.pid"))
|
|
225
|
+
mod._write_pid_file(os.getpid(), parent_pid=os.getppid())
|
|
226
|
+
with open(mod.PID_FILE, "r", encoding="utf-8") as fh:
|
|
227
|
+
raw = json.load(fh)
|
|
228
|
+
assert raw.get("pid") == os.getpid()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_existing_watcher_running_json_and_plain_moved(tmp_path, monkeypatch):
|
|
232
|
+
mod = load_watcher_module()
|
|
233
|
+
pid_file = tmp_path / ".daemon.pid"
|
|
234
|
+
# JSON metadata with entrypoint
|
|
235
|
+
pid_file.write_text(
|
|
236
|
+
__import__("json").dumps(
|
|
237
|
+
{"pid": 1111, "cmdline": "python foo", "entrypoint": "pycharm-watcher"}
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
monkeypatch.setattr(watcher, "PID_FILE", str(pid_file))
|
|
241
|
+
# simulate get_cmdline returning a matching string
|
|
242
|
+
monkeypatch.setattr(
|
|
243
|
+
watcher,
|
|
244
|
+
"_get_cmdline_for_pid_local",
|
|
245
|
+
staticmethod(lambda p: "python .collab/pycharm/live_locks_mod.py"),
|
|
246
|
+
)
|
|
247
|
+
ok, pid, cmd, entry = mod._existing_watcher_running()
|
|
248
|
+
assert ok and pid == 1111
|
|
249
|
+
|
|
250
|
+
# plain integer pid
|
|
251
|
+
pid_file.write_text(str(2222))
|
|
252
|
+
monkeypatch.setattr(
|
|
253
|
+
watcher, "_get_cmdline_for_pid_local", staticmethod(lambda p: None)
|
|
254
|
+
)
|
|
255
|
+
ok2, pid2, cmd2, entry2 = mod._existing_watcher_running()
|
|
256
|
+
# Without cmdline match, should return False but pid present
|
|
257
|
+
assert (ok2 is False) and pid2 == 2222
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_get_session_token_handles_component_exceptions(monkeypatch):
|
|
261
|
+
"""_get_session_token should use safe fallbacks if component derivation fails."""
|
|
262
|
+
mod = load_watcher_module()
|
|
263
|
+
|
|
264
|
+
class BadDev:
|
|
265
|
+
def __str__(self):
|
|
266
|
+
raise RuntimeError("bad str")
|
|
267
|
+
|
|
268
|
+
monkeypatch.setattr(
|
|
269
|
+
mod.socket,
|
|
270
|
+
"gethostname",
|
|
271
|
+
lambda: (_ for _ in ()).throw(RuntimeError("no host")),
|
|
272
|
+
)
|
|
273
|
+
monkeypatch.setattr(
|
|
274
|
+
mod.os.path, "abspath", lambda p: (_ for _ in ()).throw(RuntimeError("no path"))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
token = mod._get_session_token(BadDev())
|
|
278
|
+
assert isinstance(token, str)
|
|
279
|
+
assert len(token) == 16
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_is_same_machine_token_matches_env_user_when_git_fails(monkeypatch):
|
|
283
|
+
"""_is_same_machine_token can match using env-user candidate when git lookup
|
|
284
|
+
fails."""
|
|
285
|
+
mod = load_watcher_module()
|
|
286
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", None)
|
|
287
|
+
monkeypatch.setenv("USERNAME", "alice")
|
|
288
|
+
|
|
289
|
+
monkeypatch.setattr(mod.socket, "gethostname", lambda: "hostA")
|
|
290
|
+
monkeypatch.setattr(mod.os.path, "abspath", lambda p: "C:/repo")
|
|
291
|
+
|
|
292
|
+
# Force git-config path to fail
|
|
293
|
+
monkeypatch.setattr(
|
|
294
|
+
mod.subprocess,
|
|
295
|
+
"check_output",
|
|
296
|
+
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("git fail")),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
import hashlib
|
|
300
|
+
|
|
301
|
+
seed = "alice:hosta:c:/repo"
|
|
302
|
+
expected = hashlib.sha256(seed.encode()).hexdigest()[:16]
|
|
303
|
+
assert mod._is_same_machine_token(expected) is True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_is_same_machine_token_returns_false_for_unknown_token(monkeypatch):
|
|
307
|
+
"""_is_same_machine_token returns False when no candidate seed matches."""
|
|
308
|
+
mod = load_watcher_module()
|
|
309
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "bob")
|
|
310
|
+
monkeypatch.setenv("USERNAME", "bob")
|
|
311
|
+
monkeypatch.setattr(mod.socket, "gethostname", lambda: "hostB")
|
|
312
|
+
monkeypatch.setattr(mod.os.path, "abspath", lambda p: "C:/repo")
|
|
313
|
+
|
|
314
|
+
# Keep git deterministic too
|
|
315
|
+
monkeypatch.setattr(mod.subprocess, "check_output", lambda *a, **k: b"bob\n")
|
|
316
|
+
|
|
317
|
+
assert mod._is_same_machine_token("0000000000000000") is False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# New test: malformed PID JSON should be treated as no existing watcher
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_existing_watcher_running_with_malformed_json(tmp_path):
|
|
324
|
+
mod = load_watcher_module()
|
|
325
|
+
# Write malformed JSON to PID file and ensure helper treats it as no watcher
|
|
326
|
+
pid_file = tmp_path / ".daemon.pid"
|
|
327
|
+
pid_file.write_text("{not: json}")
|
|
328
|
+
orig = mod.PID_FILE
|
|
329
|
+
try:
|
|
330
|
+
mod.PID_FILE = str(pid_file)
|
|
331
|
+
running, pid, cmd, entry = mod._existing_watcher_running()
|
|
332
|
+
assert running is False and pid is None
|
|
333
|
+
finally:
|
|
334
|
+
mod.PID_FILE = orig
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_is_process_alive_fallback_without_psutil_moved(monkeypatch):
|
|
338
|
+
mod = load_watcher_module()
|
|
339
|
+
# Simulate ImportError for psutil and make tasklist command fail
|
|
340
|
+
import builtins as _builtins
|
|
341
|
+
|
|
342
|
+
real_import = _builtins.__import__
|
|
343
|
+
|
|
344
|
+
def fake_import(name, *a, **k):
|
|
345
|
+
if name == "psutil":
|
|
346
|
+
raise ImportError("no psutil")
|
|
347
|
+
return real_import(name, *a, **k)
|
|
348
|
+
|
|
349
|
+
monkeypatch.setattr(_builtins, "__import__", fake_import)
|
|
350
|
+
|
|
351
|
+
def fake_check_output(*a, **k):
|
|
352
|
+
raise Exception("tasklist failed")
|
|
353
|
+
|
|
354
|
+
monkeypatch.setattr("subprocess.check_output", fake_check_output)
|
|
355
|
+
|
|
356
|
+
# Should return False when both psutil unavailable and tasklist fails
|
|
357
|
+
assert mod._is_process_alive(999999) is False
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_get_cmdline_for_pid_local_uses_psutil(monkeypatch):
|
|
361
|
+
mod = load_watcher_module()
|
|
362
|
+
fake_psutil = types.SimpleNamespace()
|
|
363
|
+
|
|
364
|
+
class FakeProc:
|
|
365
|
+
def __init__(self, pid):
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
def cmdline(self):
|
|
369
|
+
return [sys.executable, "-c", "print(1)"]
|
|
370
|
+
|
|
371
|
+
fake_psutil.Process = FakeProc
|
|
372
|
+
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
373
|
+
|
|
374
|
+
out = mod._get_cmdline_for_pid_local(os.getpid())
|
|
375
|
+
assert out and "python" in out.lower()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_get_process_info_local_non_windows(monkeypatch):
|
|
379
|
+
"""Non-Windows platforms should skip WMIC lookup."""
|
|
380
|
+
mod = load_watcher_module()
|
|
381
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
382
|
+
assert mod._get_process_info_local(123) == (None, None)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_get_process_info_local_parses_wmic_output(monkeypatch):
|
|
386
|
+
"""Windows WMIC output with process row should be parsed."""
|
|
387
|
+
mod = load_watcher_module()
|
|
388
|
+
monkeypatch.setattr(mod.sys, "platform", "win32")
|
|
389
|
+
|
|
390
|
+
def _wmic(*args, **kwargs):
|
|
391
|
+
return b"Name ParentProcessId\ncode.exe 456\n"
|
|
392
|
+
|
|
393
|
+
monkeypatch.setattr(mod.subprocess, "check_output", _wmic)
|
|
394
|
+
assert mod._get_process_info_local(999) == ("code.exe", 456)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_get_parent_ide_pid_node_promotes_to_code(monkeypatch):
|
|
398
|
+
"""When the current process is node.exe under Code, return Code PID."""
|
|
399
|
+
mod = load_watcher_module()
|
|
400
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 100)
|
|
401
|
+
|
|
402
|
+
def _info(pid):
|
|
403
|
+
if pid == 100:
|
|
404
|
+
return ("node.exe", 200)
|
|
405
|
+
if pid == 200:
|
|
406
|
+
return ("Code.exe", 1)
|
|
407
|
+
return (None, None)
|
|
408
|
+
|
|
409
|
+
monkeypatch.setattr(mod, "_get_process_info_local", _info)
|
|
410
|
+
assert mod._get_parent_ide_pid_local() == 200
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_get_parent_ide_pid_env_and_pycharm_fallbacks(monkeypatch):
|
|
414
|
+
"""Cover VSCODE_PID alive path and PYCHARM_HOSTED fallback."""
|
|
415
|
+
mod = load_watcher_module()
|
|
416
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 10)
|
|
417
|
+
monkeypatch.setattr(mod, "_get_process_info_local", lambda pid: (None, None))
|
|
418
|
+
|
|
419
|
+
monkeypatch.setenv("VSCODE_PID", "4321")
|
|
420
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: pid == 4321)
|
|
421
|
+
assert mod._get_parent_ide_pid_local() == 4321
|
|
422
|
+
|
|
423
|
+
monkeypatch.delenv("VSCODE_PID", raising=False)
|
|
424
|
+
monkeypatch.setenv("PYCHARM_HOSTED", "1")
|
|
425
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 777)
|
|
426
|
+
assert mod._get_parent_ide_pid_local() == 777
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_get_parent_ide_pid_returns_none_when_no_candidates(monkeypatch):
|
|
430
|
+
"""If no ancestor, env PID, or parent shell exists, return None."""
|
|
431
|
+
mod = load_watcher_module()
|
|
432
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 10)
|
|
433
|
+
monkeypatch.setattr(mod, "_get_process_info_local", lambda pid: (None, None))
|
|
434
|
+
monkeypatch.delenv("VSCODE_PID", raising=False)
|
|
435
|
+
monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
|
|
436
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 0)
|
|
437
|
+
assert mod._get_parent_ide_pid_local() is None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test_get_cmdline_for_pid_local_psutil_scalar_cmdline(monkeypatch):
|
|
441
|
+
"""Psutil cmdline() returning scalar should be stringified."""
|
|
442
|
+
mod = load_watcher_module()
|
|
443
|
+
fake_psutil = types.SimpleNamespace()
|
|
444
|
+
|
|
445
|
+
class FakeProc:
|
|
446
|
+
def __init__(self, pid):
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
def cmdline(self):
|
|
450
|
+
return "python watcher"
|
|
451
|
+
|
|
452
|
+
fake_psutil.Process = FakeProc
|
|
453
|
+
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
|
454
|
+
|
|
455
|
+
out = mod._get_cmdline_for_pid_local(1)
|
|
456
|
+
assert out == "python watcher"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def test_get_cmdline_for_pid_local_non_windows_without_psutil(monkeypatch):
|
|
460
|
+
"""When psutil is unavailable on non-Windows, cmdline lookup should return None."""
|
|
461
|
+
mod = load_watcher_module()
|
|
462
|
+
monkeypatch.setattr(mod.sys, "platform", "linux")
|
|
463
|
+
|
|
464
|
+
import builtins as _builtins
|
|
465
|
+
|
|
466
|
+
real_import = _builtins.__import__
|
|
467
|
+
|
|
468
|
+
def _no_psutil(name, *a, **k):
|
|
469
|
+
if name == "psutil":
|
|
470
|
+
raise ImportError("no psutil")
|
|
471
|
+
return real_import(name, *a, **k)
|
|
472
|
+
|
|
473
|
+
monkeypatch.setattr(_builtins, "__import__", _no_psutil)
|
|
474
|
+
assert mod._get_cmdline_for_pid_local(12345) is None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_existing_watcher_running_handles_cmdline_probe_exception(
|
|
478
|
+
monkeypatch, tmp_path
|
|
479
|
+
):
|
|
480
|
+
"""Failure during cmdline probe should not crash watcher detection."""
|
|
481
|
+
mod = load_watcher_module()
|
|
482
|
+
pid_file = tmp_path / "daemon.pid"
|
|
483
|
+
pid_file.write_text(
|
|
484
|
+
json.dumps({"pid": 321, "entrypoint": "not-watcher"}), encoding="utf-8"
|
|
485
|
+
)
|
|
486
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
487
|
+
|
|
488
|
+
calls = {"n": 0}
|
|
489
|
+
|
|
490
|
+
def _cmd_probe(pid):
|
|
491
|
+
calls["n"] += 1
|
|
492
|
+
if calls["n"] == 1:
|
|
493
|
+
raise RuntimeError("probe failed")
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
monkeypatch.setattr(mod, "_get_cmdline_for_pid_local", _cmd_probe)
|
|
497
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: True)
|
|
498
|
+
|
|
499
|
+
running, pid, cmdline, entry = mod._existing_watcher_running()
|
|
500
|
+
assert running is False
|
|
501
|
+
assert pid == 321
|
|
502
|
+
assert entry == "not-watcher"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def test_existing_watcher_running_stale_pid_with_dead_parent_details(
|
|
506
|
+
monkeypatch, tmp_path
|
|
507
|
+
):
|
|
508
|
+
"""Stale watcher PID with stored parent should emit dead-parent diagnostics path."""
|
|
509
|
+
mod = load_watcher_module()
|
|
510
|
+
pid_file = tmp_path / "daemon.pid"
|
|
511
|
+
pid_file.write_text(
|
|
512
|
+
json.dumps(
|
|
513
|
+
{
|
|
514
|
+
"pid": 9999,
|
|
515
|
+
"entrypoint": "pycharm-watcher",
|
|
516
|
+
"parent_pid": 1111,
|
|
517
|
+
"started_at": "2025-01-01T00:00:00+00:00",
|
|
518
|
+
}
|
|
519
|
+
),
|
|
520
|
+
encoding="utf-8",
|
|
521
|
+
)
|
|
522
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
523
|
+
|
|
524
|
+
def _alive(pid):
|
|
525
|
+
return False
|
|
526
|
+
|
|
527
|
+
monkeypatch.setattr(mod, "_is_process_alive", _alive)
|
|
528
|
+
|
|
529
|
+
running, pid, cmdline, entry = mod._existing_watcher_running()
|
|
530
|
+
assert running is False
|
|
531
|
+
assert pid == 9999
|
|
532
|
+
assert cmdline is None
|
|
533
|
+
assert entry is None
|
|
534
|
+
assert not pid_file.exists()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_existing_watcher_running_detects_orphaned_parent(monkeypatch, tmp_path):
|
|
538
|
+
"""Alive watcher with dead stored parent should be treated as orphaned."""
|
|
539
|
+
mod = load_watcher_module()
|
|
540
|
+
pid_file = tmp_path / "daemon.pid"
|
|
541
|
+
pid_file.write_text(
|
|
542
|
+
json.dumps({"pid": 7777, "cmdline": "python something", "parent_pid": 8888}),
|
|
543
|
+
encoding="utf-8",
|
|
544
|
+
)
|
|
545
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
546
|
+
monkeypatch.setattr(mod, "_get_cmdline_for_pid_local", lambda pid: None)
|
|
547
|
+
|
|
548
|
+
def _alive(pid):
|
|
549
|
+
if pid == 7777:
|
|
550
|
+
return True
|
|
551
|
+
if pid == 8888:
|
|
552
|
+
return False
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
monkeypatch.setattr(mod, "_is_process_alive", _alive)
|
|
556
|
+
running, pid, cmdline, entry = mod._existing_watcher_running()
|
|
557
|
+
assert running is False
|
|
558
|
+
assert pid == 7777
|
|
559
|
+
assert cmdline == "python something"
|
|
560
|
+
assert entry is None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def test_get_parent_ide_pid_returns_direct_ide_and_handles_ancestor_exception(
|
|
564
|
+
monkeypatch,
|
|
565
|
+
):
|
|
566
|
+
"""Cover direct IDE return and ancestor-walk exception fallback logging path."""
|
|
567
|
+
mod = load_watcher_module()
|
|
568
|
+
|
|
569
|
+
monkeypatch.setattr(mod.os, "getpid", lambda: 42)
|
|
570
|
+
monkeypatch.setattr(
|
|
571
|
+
mod, "_get_process_info_local", lambda pid: ("pycharm64.exe", 10)
|
|
572
|
+
)
|
|
573
|
+
assert mod._get_parent_ide_pid_local() == 42
|
|
574
|
+
|
|
575
|
+
# Avoid logging internals calling os.getpid() while we force getpid to fail.
|
|
576
|
+
monkeypatch.setattr(mod.logger, "debug", lambda *a, **k: None)
|
|
577
|
+
monkeypatch.setattr(
|
|
578
|
+
mod.os, "getpid", lambda: (_ for _ in ()).throw(RuntimeError("pid fail"))
|
|
579
|
+
)
|
|
580
|
+
monkeypatch.delenv("VSCODE_PID", raising=False)
|
|
581
|
+
monkeypatch.delenv("PYCHARM_HOSTED", raising=False)
|
|
582
|
+
monkeypatch.setattr(mod.os, "getppid", lambda: 555)
|
|
583
|
+
assert mod._get_parent_ide_pid_local() == 555
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def test_existing_watcher_running_stale_pid_remove_oserror(monkeypatch, tmp_path):
|
|
587
|
+
"""OSError during stale PID removal should be swallowed and still return stale
|
|
588
|
+
state."""
|
|
589
|
+
mod = load_watcher_module()
|
|
590
|
+
pid_file = tmp_path / "daemon.pid"
|
|
591
|
+
pid_file.write_text(
|
|
592
|
+
json.dumps({"pid": 2468, "entrypoint": "pycharm-watcher"}), encoding="utf-8"
|
|
593
|
+
)
|
|
594
|
+
monkeypatch.setattr(mod, "PID_FILE", str(pid_file))
|
|
595
|
+
monkeypatch.setattr(mod, "_is_process_alive", lambda pid: False)
|
|
596
|
+
|
|
597
|
+
def _rm(path):
|
|
598
|
+
raise OSError("cannot remove")
|
|
599
|
+
|
|
600
|
+
monkeypatch.setattr(mod.os, "remove", _rm)
|
|
601
|
+
running, pid, cmdline, entry = mod._existing_watcher_running()
|
|
602
|
+
assert running is False
|
|
603
|
+
assert pid == 2468
|
|
604
|
+
assert cmdline is None
|
|
605
|
+
assert entry is None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# (removed duplicate moved variant; canonical version retained below)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# Restored archived-only original-name test (non-destructive restore)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_is_process_alive_win32_tasklist_success(monkeypatch):
|
|
615
|
+
"""_is_process_alive returns True on win32 when tasklist finds the PID (no
|
|
616
|
+
psutil)."""
|
|
617
|
+
mod = load_watcher_module()
|
|
618
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
619
|
+
|
|
620
|
+
import builtins as _builtins
|
|
621
|
+
|
|
622
|
+
real_import = _builtins.__import__
|
|
623
|
+
|
|
624
|
+
def _no_psutil(name, *a, **k):
|
|
625
|
+
if name == "psutil":
|
|
626
|
+
raise ImportError("no psutil")
|
|
627
|
+
return real_import(name, *a, **k)
|
|
628
|
+
|
|
629
|
+
monkeypatch.setattr(_builtins, "__import__", _no_psutil)
|
|
630
|
+
monkeypatch.setattr(
|
|
631
|
+
subprocess, "check_output", lambda *a, **k: "Image PID\npython.exe 99999"
|
|
632
|
+
)
|
|
633
|
+
assert mod._is_process_alive(99999) is True
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_is_process_alive_non_win32_process_alive(monkeypatch):
|
|
637
|
+
"""_is_process_alive returns True on non-win32 when os.kill succeeds."""
|
|
638
|
+
mod = load_watcher_module()
|
|
639
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
640
|
+
monkeypatch.setattr(mod.os, "kill", lambda pid, sig: None)
|
|
641
|
+
assert mod._is_process_alive(12345) is True
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_is_process_alive_non_win32_process_lookup_error(monkeypatch):
|
|
645
|
+
"""_is_process_alive returns False on non-win32 when process does not exist."""
|
|
646
|
+
mod = load_watcher_module()
|
|
647
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
648
|
+
|
|
649
|
+
def _kill_not_found(pid, sig):
|
|
650
|
+
raise ProcessLookupError("no such process")
|
|
651
|
+
|
|
652
|
+
monkeypatch.setattr(mod.os, "kill", _kill_not_found)
|
|
653
|
+
assert mod._is_process_alive(12345) is False
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_is_process_alive_non_win32_permission_error(monkeypatch):
|
|
657
|
+
"""_is_process_alive returns True on non-win32 when PermissionError (process exists
|
|
658
|
+
but not owned by this user)."""
|
|
659
|
+
mod = load_watcher_module()
|
|
660
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
661
|
+
|
|
662
|
+
def _kill_permission_denied(pid, sig):
|
|
663
|
+
raise PermissionError("access denied")
|
|
664
|
+
|
|
665
|
+
monkeypatch.setattr(mod.os, "kill", _kill_permission_denied)
|
|
666
|
+
assert mod._is_process_alive(12345) is True
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def test_get_current_branch_non_win32(monkeypatch):
|
|
670
|
+
"""_get_current_branch uses subprocess without creationflags on non- win32."""
|
|
671
|
+
mod = load_watcher_module()
|
|
672
|
+
monkeypatch.setattr(sys, "platform", "linux")
|
|
673
|
+
|
|
674
|
+
def _check_output(cmd, **kwargs):
|
|
675
|
+
assert (
|
|
676
|
+
"creationflags" not in kwargs
|
|
677
|
+
), "creationflags must not be passed on non-win32"
|
|
678
|
+
return b"main\n"
|
|
679
|
+
|
|
680
|
+
monkeypatch.setattr(subprocess, "check_output", _check_output)
|
|
681
|
+
assert mod._get_current_branch() == "main"
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
watcher = load_watcher_module()
|