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.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. 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()