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,438 @@
1
+ """Dashboard-related tests for LockClient.
2
+
3
+ Moved from the main `test_lock_client.py` for clearer organization.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import socket
10
+ import sys
11
+ import tempfile
12
+ import types
13
+ from unittest import mock
14
+
15
+ import pytest
16
+
17
+ from ._helpers import FakeResponse, load_lock_client_module, make_create_client
18
+
19
+ mod = load_lock_client_module()
20
+
21
+
22
+ def test_dashboard_opens_browser(monkeypatch):
23
+ """Test dashboard() opens a browser."""
24
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
25
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
26
+
27
+ monkeypatch.setattr(
28
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
29
+ )
30
+
31
+ opened_urls = []
32
+
33
+ def mock_prepare(self):
34
+ _tmp = os.path.join(tempfile.gettempdir(), "dash.html")
35
+ return "http://127.0.0.1:9999/dash.html", _tmp
36
+
37
+ monkeypatch.setattr(mod.LockClient, "_prepare_dashboard_server", mock_prepare)
38
+
39
+ import webbrowser
40
+
41
+ monkeypatch.setattr(webbrowser, "open", lambda url: opened_urls.append(url))
42
+
43
+ lc = mod.LockClient(developer_id="test_user")
44
+ lc.dashboard()
45
+ assert len(opened_urls) == 1
46
+
47
+
48
+ def test_dashboard_no_url(monkeypatch):
49
+ """Test dashboard() when _prepare_dashboard_server returns None."""
50
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
51
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
52
+
53
+ monkeypatch.setattr(
54
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
55
+ )
56
+
57
+ def mock_prepare(self):
58
+ return None, None
59
+
60
+ monkeypatch.setattr(mod.LockClient, "_prepare_dashboard_server", mock_prepare)
61
+
62
+ lc = mod.LockClient(developer_id="test_user")
63
+ lc.dashboard() # Should return early without error
64
+
65
+
66
+ def test_dashboard_browser_exception(monkeypatch, capsys):
67
+ """Test dashboard() handles browser open failure."""
68
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
69
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
70
+
71
+ monkeypatch.setattr(
72
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
73
+ )
74
+
75
+ def mock_prepare(self):
76
+ _tmp = os.path.join(tempfile.gettempdir(), "dash.html")
77
+ return "http://127.0.0.1:9999/dash.html", _tmp
78
+
79
+ monkeypatch.setattr(mod.LockClient, "_prepare_dashboard_server", mock_prepare)
80
+
81
+ import webbrowser
82
+
83
+ monkeypatch.setattr(
84
+ webbrowser, "open", mock.Mock(side_effect=Exception("No browser"))
85
+ )
86
+
87
+ lc = mod.LockClient(developer_id="test_user")
88
+ lc.dashboard()
89
+ captured = capsys.readouterr()
90
+ assert "open in browser" in captured.out.lower() or "http" in captured.out.lower()
91
+
92
+
93
+ def test_prepare_dashboard_server_missing_html(monkeypatch):
94
+ """Test _prepare_dashboard_server when index.html is missing."""
95
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
96
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
97
+
98
+ monkeypatch.setattr(
99
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
100
+ )
101
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", "/nonexistent/path")
102
+
103
+ lc = mod.LockClient(developer_id="test_user")
104
+ url, tmp_path = lc._prepare_dashboard_server()
105
+ assert url is None
106
+ assert tmp_path is None
107
+
108
+
109
+ def test_prepare_dashboard_server_success(monkeypatch, tmp_path):
110
+ """Test _prepare_dashboard_server creates server and returns URL."""
111
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
112
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
113
+
114
+ # Create fake dashboard directory with index.html
115
+ dash_dir = tmp_path / "dashboard"
116
+ dash_dir.mkdir()
117
+ (dash_dir / "index.html").write_text("<html><body>Test</body></html>")
118
+
119
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
120
+ monkeypatch.setattr(
121
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
122
+ )
123
+
124
+ lc = mod.LockClient(developer_id="test_user")
125
+ url, tmp_file = lc._prepare_dashboard_server()
126
+
127
+ # Should return a valid URL
128
+ if url:
129
+ assert "http://127.0.0.1" in url
130
+ # Clean up temp file if created
131
+ if tmp_file and os.path.exists(tmp_file):
132
+ os.unlink(tmp_file)
133
+
134
+
135
+ def test_prepare_dashboard_server_read_error(monkeypatch, tmp_path):
136
+ """Test _prepare_dashboard_server when reading HTML file fails."""
137
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
138
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
139
+
140
+ # Create dashboard dir with index.html, but make it unreadable
141
+ dash_dir = tmp_path / "dashboard"
142
+ dash_dir.mkdir()
143
+ html_file = dash_dir / "index.html"
144
+ html_file.write_text("<html></html>")
145
+
146
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
147
+ monkeypatch.setattr(
148
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
149
+ )
150
+
151
+ # Mock open to raise IOError on the specific file
152
+ original_open = open
153
+
154
+ def failing_open(path, *args, **kwargs):
155
+ if "index.html" in str(path):
156
+ raise IOError("Permission denied")
157
+ return original_open(path, *args, **kwargs)
158
+
159
+ monkeypatch.setattr("builtins.open", failing_open)
160
+
161
+ lc = mod.LockClient(developer_id="test_user")
162
+ url, tmp_file = lc._prepare_dashboard_server()
163
+ assert url is None
164
+
165
+
166
+ def test_prepare_dashboard_server_tmpfile_error(monkeypatch, tmp_path):
167
+ """Test _prepare_dashboard_server when tmpfile creation fails."""
168
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
169
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
170
+
171
+ dash_dir = tmp_path / "dashboard"
172
+ dash_dir.mkdir()
173
+ (dash_dir / "index.html").write_text("<html></html>")
174
+
175
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
176
+ monkeypatch.setattr(
177
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
178
+ )
179
+
180
+ # Mock tempfile to raise
181
+ import tempfile
182
+
183
+ monkeypatch.setattr(
184
+ tempfile,
185
+ "NamedTemporaryFile",
186
+ mock.Mock(side_effect=OSError("Disk full")),
187
+ )
188
+
189
+ lc = mod.LockClient(developer_id="test_user")
190
+ url, tmp_file = lc._prepare_dashboard_server()
191
+ assert url is None
192
+ assert tmp_file is None
193
+
194
+
195
+ def test_prepare_dashboard_server_http_error(monkeypatch, tmp_path):
196
+ """Test _prepare_dashboard_server when HTTP server fails."""
197
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
198
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
199
+
200
+ dash_dir = tmp_path / "dashboard"
201
+ dash_dir.mkdir()
202
+ (dash_dir / "index.html").write_text("<html></html>")
203
+
204
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
205
+ monkeypatch.setattr(
206
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
207
+ )
208
+
209
+ # Mock http.server.ThreadingHTTPServer to raise
210
+ import http.server
211
+
212
+ monkeypatch.setattr(
213
+ http.server,
214
+ "ThreadingHTTPServer",
215
+ mock.Mock(side_effect=OSError("Port error")),
216
+ )
217
+
218
+ lc = mod.LockClient(developer_id="test_user")
219
+ url, tmp_file = lc._prepare_dashboard_server()
220
+ assert url is None
221
+ assert tmp_file is None
222
+
223
+
224
+ def test_prepare_dashboard_server_socket_probe_failure(monkeypatch, tmp_path):
225
+ """Test _prepare_dashboard_server socket probe retry."""
226
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
227
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
228
+
229
+ dash_dir = tmp_path / "dashboard"
230
+ dash_dir.mkdir()
231
+ (dash_dir / "index.html").write_text("<html></html>")
232
+
233
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
234
+ monkeypatch.setattr(
235
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
236
+ )
237
+
238
+ import http.server
239
+ import socket as _socket
240
+
241
+ # Create a real-ish server mock that binds but returns a port
242
+ # where sockets will initially fail then succeed
243
+ probe_count = [0]
244
+ original_create_connection = _socket.create_connection
245
+
246
+ def flaky_connection(addr, timeout=None):
247
+ probe_count[0] += 1
248
+ if probe_count[0] <= 2:
249
+ raise ConnectionRefusedError("not ready yet")
250
+ return original_create_connection(addr, timeout=timeout)
251
+
252
+ class FakeServerForProbe:
253
+ def __init__(self, addr, handler):
254
+ self.server_address = ("127.0.0.1", 19876)
255
+
256
+ def serve_forever(self):
257
+ pass
258
+
259
+ def shutdown(self):
260
+ pass
261
+
262
+ monkeypatch.setattr(http.server, "ThreadingHTTPServer", FakeServerForProbe)
263
+ monkeypatch.setattr(_socket, "create_connection", flaky_connection)
264
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
265
+
266
+ lc = mod.LockClient(developer_id="test_user")
267
+ url, tmp_file = lc._prepare_dashboard_server()
268
+
269
+ # Should succeed after retries
270
+ if url:
271
+ assert "http://127.0.0.1" in url
272
+ if tmp_file and os.path.exists(tmp_file):
273
+ os.unlink(tmp_file)
274
+
275
+
276
+ def test_prepare_dashboard_server_unlink_error(monkeypatch, tmp_path):
277
+ """Test _prepare_dashboard_server handles unlink error."""
278
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
279
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
280
+
281
+ dash_dir = tmp_path / "dashboard"
282
+ dash_dir.mkdir()
283
+ (dash_dir / "index.html").write_text("<html></html>")
284
+
285
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
286
+ monkeypatch.setattr(
287
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
288
+ )
289
+
290
+ import http.server
291
+
292
+ # Server creation raises, triggering the except block
293
+ def raise_on_create(addr, handler):
294
+ raise OSError("Cannot bind")
295
+
296
+ monkeypatch.setattr(http.server, "ThreadingHTTPServer", raise_on_create)
297
+
298
+ # Also mock os.unlink to raise, covering unlink error
299
+ original_unlink = os.unlink
300
+
301
+ def failing_unlink(path):
302
+ if path.endswith(".html"):
303
+ raise OSError("Permission denied on unlink")
304
+ return original_unlink(path)
305
+
306
+ monkeypatch.setattr(os, "unlink", failing_unlink)
307
+
308
+ lc = mod.LockClient(developer_id="test_user")
309
+ url, tmp_file = lc._prepare_dashboard_server()
310
+ assert url is None
311
+ assert tmp_file is None
312
+
313
+
314
+ # --- Appended from test_lock_client_dashboard_cli_cleanup.py ---
315
+
316
+
317
+ def test_prepare_dashboard_server_cli_migrated(monkeypatch, tmp_path):
318
+ mod = load_lock_client_module()
319
+
320
+ # Use a temporary .collab/dashboard directory
321
+ monkeypatch.setattr(mod, "_RESOURCE_ROOT", str(tmp_path))
322
+ d = tmp_path / "dashboard"
323
+ d.mkdir(parents=True, exist_ok=True)
324
+ html = d / "index.html"
325
+ html.write_text("<html>dashboard</html>")
326
+
327
+ # Fake ThreadingHTTPServer and Thread so we don't actually bind a port
328
+ import http.server as _http
329
+ import threading as _threading
330
+
331
+ class FakeServer:
332
+ def __init__(self, addr, handler):
333
+ self.server_address = ("127.0.0.1", 54321)
334
+
335
+ def serve_forever(self):
336
+ return None
337
+
338
+ def shutdown(self):
339
+ return None
340
+
341
+ class FakeThread:
342
+ def __init__(self, target, daemon=True):
343
+ self._target = target
344
+
345
+ def start(self):
346
+ return None
347
+
348
+ monkeypatch.setattr(_http, "ThreadingHTTPServer", FakeServer)
349
+ monkeypatch.setattr(_threading, "Thread", FakeThread)
350
+
351
+ # Make socket.create_connection succeed immediately
352
+ class DummyConn:
353
+ def __enter__(self):
354
+ return self
355
+
356
+ def __exit__(self, exc_type, exc, tb):
357
+ return False
358
+
359
+ monkeypatch.setattr(socket, "create_connection", lambda *a, **k: DummyConn())
360
+
361
+ url, tmpfile = mod.LockClient(local_only=True)._prepare_dashboard_server()
362
+ assert url and tmpfile
363
+ txt = open(tmpfile, "r", encoding="utf-8").read()
364
+ assert "window.__SUPABASE_CONFIG__" in txt
365
+ try:
366
+ os.unlink(tmpfile)
367
+ except Exception:
368
+ pass
369
+
370
+
371
+ def test_cleanup_orphaned_processes_unix_cli(monkeypatch, capsys):
372
+ mod = load_lock_client_module()
373
+ # Force UNIX branch
374
+ monkeypatch.setattr(sys, "platform", "linux", raising=False)
375
+ monkeypatch.setenv("COLLAB_TEST_MODE", "1")
376
+
377
+ out_line = "root 9999 0.0 0 0 ? S 0:00 python collab_test_lock_client"
378
+
379
+ def fake_run(*a, **k):
380
+ return types.SimpleNamespace(stdout=out_line)
381
+
382
+ monkeypatch.setattr("subprocess.run", fake_run)
383
+
384
+ killed = {"n": 0}
385
+
386
+ def fake_kill(pid, sig):
387
+ killed["n"] += 1
388
+
389
+ monkeypatch.setattr("os.kill", fake_kill)
390
+
391
+ client = mod.LockClient(local_only=True)
392
+ client._remove_pid = lambda: None
393
+ client.cleanup_orphaned_processes()
394
+ captured = capsys.readouterr()
395
+ assert killed["n"] >= 1
396
+ assert "Killing orphaned" in captured.out or "Killed" in captured.out
397
+
398
+
399
+ def test_cli_force_release_all_and_cleanup_migrated(monkeypatch):
400
+ mod = load_lock_client_module()
401
+
402
+ class FakeLockClient:
403
+ last = None
404
+
405
+ def __init__(self, local_only=False):
406
+ FakeLockClient.last = self
407
+ self.is_admin = True
408
+
409
+ def force_release_all(self):
410
+ return 3
411
+
412
+ def cleanup_orphaned_processes(self):
413
+ self.cleaned = True
414
+
415
+ monkeypatch.setattr(mod, "LockClient", FakeLockClient)
416
+
417
+ # force-release-all should exit 0 when admin
418
+ monkeypatch.setattr( # type: ignore[arg-type]
419
+ sys, "argv", ["prog", "force-release-all"]
420
+ )
421
+ with pytest.raises(SystemExit) as exc:
422
+ mod._run_cli()
423
+ assert exc.value.code == 0
424
+
425
+ # cleanup should call the instance method (no SystemExit expected)
426
+ monkeypatch.setattr(sys, "argv", ["prog", "cleanup"]) # type: ignore[arg-type]
427
+ mod._run_cli()
428
+ assert getattr(FakeLockClient.last, "cleaned", False) is True
429
+
430
+
431
+ def test_get_parent_ide_pid_vscode_cli_migrated(monkeypatch):
432
+ mod = load_lock_client_module()
433
+ monkeypatch.setenv("VSCODE_PID", "4321")
434
+ client = mod.LockClient(local_only=True)
435
+ monkeypatch.setattr(client, "_is_process_alive", lambda pid: True)
436
+ pid, method = client._get_parent_ide_pid()
437
+ assert pid == 4321
438
+ assert method == "vscode_pid"
@@ -0,0 +1,241 @@
1
+ import os
2
+ import sys
3
+ import types
4
+
5
+ from ._helpers import load_lock_client_module
6
+
7
+
8
+ def test_discover_running_watchers_with_psutil_workspace_match(monkeypatch):
9
+ mod = load_lock_client_module()
10
+
11
+ class FakeProc:
12
+ def __init__(self, pid, cmdline):
13
+ self.info = {"pid": pid, "cmdline": cmdline}
14
+
15
+ def fake_process_iter(attrs=("pid", "cmdline")):
16
+ # One matching watcher (references project root), one ignored (current pid)
17
+ return [
18
+ FakeProc(
19
+ 4242,
20
+ [
21
+ mod._PROJECT_ROOT,
22
+ ".collab/pycharm/live_locks_watcher.py",
23
+ "--pid-file",
24
+ mod.PID_FILE,
25
+ ],
26
+ ),
27
+ FakeProc(os.getpid(), ["python", "other"]),
28
+ ]
29
+
30
+ fake_psutil = types.SimpleNamespace(process_iter=fake_process_iter)
31
+ monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
32
+
33
+ client = mod.LockClient(local_only=True)
34
+ found = client._discover_running_watchers()
35
+ assert isinstance(found, list)
36
+ assert 4242 in found
37
+
38
+
39
+ # -- appended migrated process-helper tests from extra split --
40
+ mod = load_lock_client_module()
41
+
42
+
43
+ def test_is_process_alive_win32_no_psutil_ctypes_fallback(monkeypatch):
44
+ import sys as _sys
45
+
46
+ _sys.platform = "win32"
47
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
48
+
49
+ fake_ctypes = types.SimpleNamespace(
50
+ windll=types.SimpleNamespace(
51
+ kernel32=types.SimpleNamespace(
52
+ OpenProcess=lambda a, b, c: 123,
53
+ GetExitCodeProcess=lambda h, ec: (setattr(ec, "value", 259) or True),
54
+ CloseHandle=lambda h: None,
55
+ GetLastError=lambda: 0,
56
+ )
57
+ ),
58
+ c_ulong=lambda v: type("ULong", (), {"value": v})(),
59
+ byref=lambda x: x,
60
+ Structure=type("Structure", (), {}),
61
+ )
62
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
63
+
64
+ result = mod.LockClient._is_process_alive(os.getpid())
65
+ assert result is True
66
+
67
+
68
+ def test_is_process_alive_win32_process_exited(monkeypatch):
69
+ import sys as _sys
70
+
71
+ _sys.platform = "win32"
72
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
73
+
74
+ fake_ctypes = types.SimpleNamespace(
75
+ windll=types.SimpleNamespace(
76
+ kernel32=types.SimpleNamespace(
77
+ OpenProcess=lambda a, b, c: 123,
78
+ GetExitCodeProcess=lambda h, ec: (setattr(ec, "value", 1) or True),
79
+ CloseHandle=lambda h: None,
80
+ GetLastError=lambda: 0,
81
+ )
82
+ ),
83
+ c_ulong=lambda v: type("ULong", (), {"value": v})(),
84
+ byref=lambda x: x,
85
+ Structure=type("Structure", (), {}),
86
+ )
87
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
88
+
89
+ result = mod.LockClient._is_process_alive(99999)
90
+ assert result is False
91
+
92
+
93
+ def test_is_process_alive_win32_access_denied(monkeypatch):
94
+ import sys as _sys
95
+
96
+ _sys.platform = "win32"
97
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
98
+
99
+ class FakeKernel32:
100
+ def OpenProcess(self, a, b, c):
101
+ return 0
102
+
103
+ def GetLastError(self):
104
+ return 5
105
+
106
+ def CloseHandle(self, h):
107
+ pass
108
+
109
+ def GetExitCodeProcess(self, h, ec):
110
+ return False
111
+
112
+ fake_ctypes = types.SimpleNamespace(
113
+ windll=types.SimpleNamespace(kernel32=FakeKernel32()),
114
+ c_ulong=lambda v: type("ULong", (), {"value": v})(),
115
+ byref=lambda x: x,
116
+ Structure=type("Structure", (), {}),
117
+ )
118
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
119
+
120
+ result = mod.LockClient._is_process_alive(4)
121
+ assert result is True
122
+
123
+
124
+ def test_is_process_alive_win32_tasklist_fallback(monkeypatch):
125
+ import sys as _sys
126
+
127
+ _sys.platform = "win32"
128
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
129
+
130
+ class FailKernel32:
131
+ def OpenProcess(self, a, b, c):
132
+ raise Exception("ctypes failing")
133
+
134
+ def GetLastError(self):
135
+ return 0
136
+
137
+ fake_ctypes = types.SimpleNamespace(
138
+ windll=types.SimpleNamespace(kernel32=FailKernel32()),
139
+ c_ulong=lambda v: type("ULong", (), {"value": v})(),
140
+ byref=lambda x: x,
141
+ Structure=type("Structure", (), {}),
142
+ )
143
+ monkeypatch.setitem(sys.modules, "ctypes", fake_ctypes)
144
+
145
+ def fake_check_output(cmd, **kw):
146
+ if "tasklist" in str(cmd):
147
+ return f"python.exe {os.getpid()} Console 1 12345 KB"
148
+ return b""
149
+
150
+ monkeypatch.setattr(mod.subprocess, "check_output", fake_check_output)
151
+
152
+ result = mod.LockClient._is_process_alive(os.getpid())
153
+ assert result is True
154
+
155
+
156
+ def test_is_process_alive_unix(monkeypatch):
157
+ monkeypatch.setattr(sys, "platform", "linux")
158
+ monkeypatch.setattr(mod.os, "kill", lambda pid, sig: None)
159
+ result = mod.LockClient._is_process_alive(12345)
160
+ assert result is True
161
+
162
+
163
+ def test_is_process_alive_unix_dead(monkeypatch):
164
+ monkeypatch.setattr(sys, "platform", "linux")
165
+
166
+ def raise_lookup(pid, sig):
167
+ raise ProcessLookupError()
168
+
169
+ monkeypatch.setattr(mod.os, "kill", raise_lookup)
170
+ result = mod.LockClient._is_process_alive(99999)
171
+ assert result is False
172
+
173
+
174
+ def test_discover_running_watchers_no_psutil_win32(monkeypatch):
175
+ monkeypatch.setattr(sys, "platform", "win32")
176
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
177
+
178
+ def fake_subprocess_run(cmd, **kw):
179
+ if "python.exe" in str(cmd):
180
+ return types.SimpleNamespace(
181
+ stdout='"python.exe","%d","Console","1","12345 K"\n' % os.getpid(),
182
+ returncode=0,
183
+ )
184
+ return types.SimpleNamespace(stdout="", returncode=0)
185
+
186
+ monkeypatch.setattr(mod.subprocess, "run", fake_subprocess_run)
187
+ monkeypatch.setattr(
188
+ mod.LockClient,
189
+ "_get_cmdline_for_pid",
190
+ lambda self, pid: "python lock_client.py",
191
+ )
192
+ monkeypatch.setattr(
193
+ mod.LockClient, "_cmdline_matches_watcher", staticmethod(lambda cmd: True)
194
+ )
195
+
196
+ client = mod.LockClient(local_only=True)
197
+ result = client._discover_running_watchers()
198
+ assert isinstance(result, list)
199
+
200
+
201
+ def test_discover_running_watchers_unix_no_psutil(monkeypatch):
202
+ monkeypatch.setattr(sys, "platform", "linux")
203
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
204
+
205
+ def fake_run(cmd, **kw):
206
+ stdout = "12345 python /path/.collab/core/lock_client.py watch\n"
207
+ return types.SimpleNamespace(stdout=stdout, returncode=0)
208
+
209
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
210
+ monkeypatch.setattr(
211
+ mod.LockClient, "_cmdline_matches_watcher", staticmethod(lambda cmd: True)
212
+ )
213
+
214
+ client = mod.LockClient(local_only=True)
215
+ result = client._discover_running_watchers()
216
+ assert isinstance(result, list)
217
+
218
+
219
+ def test_get_process_info_local_non_windows(monkeypatch):
220
+ monkeypatch.setattr(sys, "platform", "linux")
221
+ client = mod.LockClient(local_only=True)
222
+ name, ppid = client._get_process_info_local(12345)
223
+ assert name is None and ppid is None
224
+
225
+
226
+ def test_get_process_info_local_no_wmic_tasklist_fallback(monkeypatch):
227
+ monkeypatch.setattr(sys, "platform", "win32")
228
+ monkeypatch.delitem(sys.modules, "psutil", raising=False)
229
+ monkeypatch.setattr(mod.shutil, "which", lambda cmd: None if cmd == "wmic" else cmd)
230
+
231
+ def fake_run(cmd, **kw):
232
+ return types.SimpleNamespace(
233
+ stdout='"python.exe","12345","Console","1","12345 K"\n',
234
+ returncode=0,
235
+ )
236
+
237
+ monkeypatch.setattr(mod.subprocess, "run", fake_run)
238
+
239
+ client = mod.LockClient(local_only=True)
240
+ name = client._get_process_name_via_tasklist(12345)
241
+ assert name == "python.exe"