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,187 @@
1
+ """Module-level tests for live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import sys
8
+ import types
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from ._helpers import load_watcher_module
14
+
15
+
16
+ def _repo_root() -> Path:
17
+ here = Path(__file__).resolve()
18
+ for parent in here.parents:
19
+ if (parent / "pyproject.toml").exists() and (parent / "src").exists():
20
+ return parent
21
+ raise FileNotFoundError("Could not locate repository root")
22
+
23
+
24
+ def test_main_function_exists():
25
+ mod = load_watcher_module()
26
+ assert hasattr(mod, "main") and callable(mod.main)
27
+
28
+
29
+ def test_module_imports():
30
+ mod = load_watcher_module()
31
+ assert hasattr(mod, "main")
32
+ assert hasattr(mod, "_parse_git_status_path")
33
+ assert hasattr(mod, "_should_ignore_path")
34
+
35
+
36
+ def test_main_block_present():
37
+ module_file = _repo_root().joinpath("src/live_locks_watcher.py")
38
+ src = module_file.read_text(encoding="utf-8")
39
+ assert '__name__ == "__main__"' in src or "__name__ == '__main__'" in src
40
+
41
+
42
+ def test_reload_watcher_with_colorama_and_plyer(monkeypatch):
43
+ """Reload the watcher module with fake colorama and plyer to exercise optional-
44
+ import branches executed at module import time."""
45
+ fake_colorama = types.SimpleNamespace(
46
+ Fore=types.SimpleNamespace(GREEN="G", YELLOW="Y", CYAN="C", MAGENTA="M"),
47
+ Style=types.SimpleNamespace(RESET_ALL="R"),
48
+ init=lambda: None,
49
+ )
50
+ fake_plyer = types.SimpleNamespace(
51
+ notification=types.SimpleNamespace(
52
+ notify=lambda **k: None,
53
+ ),
54
+ )
55
+ fake_supa = types.SimpleNamespace(create_client=lambda url, key: object())
56
+
57
+ # Inject into sys.modules and monkeypatch find_spec so importlib sees them
58
+ sys.modules["colorama"] = fake_colorama
59
+ sys.modules["plyer"] = types.SimpleNamespace(notification=fake_plyer.notification)
60
+ sys.modules["supabase"] = fake_supa
61
+ orig_find_spec = importlib.util.find_spec
62
+ monkeypatch.setattr(importlib.util, "find_spec", lambda name: object())
63
+
64
+ try:
65
+ spec = importlib.util.spec_from_file_location(
66
+ "tmp_watcher",
67
+ _repo_root().joinpath("src/live_locks_watcher.py"),
68
+ )
69
+ assert spec and spec.loader
70
+ mod = importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(mod) # type: ignore[arg-defined]
72
+
73
+ # Basic smoke checks on functions that depend on the optional imports
74
+ assert callable(mod._color)
75
+ # _notify should use our fake notification without raising
76
+ mod._notify("T", "M")
77
+ finally:
78
+ for name in ("colorama", "plyer", "supabase"):
79
+ try:
80
+ del sys.modules[name]
81
+ except KeyError:
82
+ pass
83
+ import importlib as _importlib
84
+
85
+ monkeypatch.setattr(_importlib.util, "find_spec", orig_find_spec)
86
+
87
+
88
+ def test_color_without_colorama():
89
+ mod = load_watcher_module()
90
+ mod._HAS_COLORAMA = False
91
+ out = mod._color("hello", "X")
92
+ assert out == "hello"
93
+
94
+
95
+ def test_setup_collab_logging_fallback_to_basic_config(monkeypatch):
96
+ """setup_collab_logging falls back to basicConfig when _setup_collab_logging_obj is
97
+ None."""
98
+ mod = load_watcher_module()
99
+ monkeypatch.setattr(mod, "_setup_collab_logging_obj", None)
100
+ called = []
101
+ monkeypatch.setattr(
102
+ mod.logging, "basicConfig", lambda **kwargs: called.append(kwargs)
103
+ )
104
+ mod.setup_collab_logging(collab_dir="/fake/collab")
105
+ assert called, "basicConfig should be called when _setup_collab_logging_obj is None"
106
+
107
+
108
+ def test_reload_watcher_handles_find_spec_exceptions(monkeypatch):
109
+ """Import-time optional dependency probes should tolerate find_spec errors."""
110
+ module_path = _repo_root().joinpath("src/live_locks_watcher.py")
111
+
112
+ def _raising_find_spec(_name):
113
+ raise RuntimeError("probe failed")
114
+
115
+ monkeypatch.setattr(importlib.util, "find_spec", _raising_find_spec)
116
+
117
+ spec = importlib.util.spec_from_file_location("tmp_watcher_probe_fail", module_path)
118
+ assert spec and spec.loader
119
+ mod = importlib.util.module_from_spec(spec)
120
+ spec.loader.exec_module(mod) # type: ignore[arg-defined]
121
+
122
+ # Module should still import with optional dependencies disabled.
123
+ assert mod._HAS_COLORAMA is False
124
+ assert mod.create_client is None
125
+ assert mod.desktop_notify is None
126
+
127
+
128
+ def test_reload_watcher_exits_on_local_supabase_shadow(monkeypatch):
129
+ """Watcher should abort when a local .collab/supabase module shadows package."""
130
+ module_path = _repo_root().joinpath("src/live_locks_watcher.py")
131
+ # Force a deterministic runtime collab root so shadow detection doesn't
132
+ # depend on the process cwd/environment.
133
+ monkeypatch.setenv("COLLAB_HOME", str(module_path.parent))
134
+ fake_supa_spec = types.SimpleNamespace(
135
+ origin=str(module_path.parent / "supabase.py")
136
+ )
137
+
138
+ def _find_spec(name):
139
+ if name == "supabase":
140
+ return fake_supa_spec
141
+ return None
142
+
143
+ monkeypatch.setattr(importlib.util, "find_spec", _find_spec)
144
+
145
+ spec = importlib.util.spec_from_file_location(
146
+ "tmp_watcher_shadowed_supa", module_path
147
+ )
148
+ assert spec and spec.loader
149
+ mod = importlib.util.module_from_spec(spec)
150
+
151
+ with pytest.raises(SystemExit):
152
+ spec.loader.exec_module(mod) # type: ignore[arg-defined]
153
+
154
+
155
+ def test_watcher_allows_project_venv_site_packages_origin(monkeypatch):
156
+ """Watcher import should allow installed packages from a repo-local virtualenv."""
157
+ module_path = _repo_root().joinpath("src/live_locks_watcher.py")
158
+ project_root = module_path.parents[2]
159
+ monkeypatch.delenv("COLLAB_HOME", raising=False)
160
+ monkeypatch.setenv("COLLAB_PROJECT_ROOT", str(project_root))
161
+ fake_supa_spec = types.SimpleNamespace(
162
+ origin=str(
163
+ project_root
164
+ / ".venv"
165
+ / "Lib"
166
+ / "site-packages"
167
+ / "supabase"
168
+ / "__init__.py"
169
+ )
170
+ )
171
+
172
+ def _find_spec(name):
173
+ if name == "supabase":
174
+ return fake_supa_spec
175
+ return None
176
+
177
+ fake_supa = types.SimpleNamespace(create_client=lambda *_a, **_k: object())
178
+ monkeypatch.setitem(sys.modules, "supabase", fake_supa)
179
+ monkeypatch.setattr(importlib.util, "find_spec", _find_spec)
180
+
181
+ spec = importlib.util.spec_from_file_location(
182
+ "tmp_watcher_site_packages_supa", module_path
183
+ )
184
+ assert spec and spec.loader
185
+ mod = importlib.util.module_from_spec(spec)
186
+ spec.loader.exec_module(mod) # type: ignore[arg-defined]
187
+ assert callable(mod.create_client)
@@ -0,0 +1,320 @@
1
+ """Multi-session and post-restart conflict handling tests for live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from ._helpers import load_watcher_module
10
+
11
+
12
+ def test_handle_multi_session_interactive_readopt_choice_1(monkeypatch):
13
+ """Test interactive multi-session lock re-adopt (choice 1)."""
14
+ mod = load_watcher_module()
15
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
16
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
17
+
18
+ import builtins
19
+
20
+ monkeypatch.setattr(builtins, "input", lambda p: "1")
21
+
22
+ update_called = []
23
+
24
+ class FakeTable:
25
+ def update(self, *args):
26
+ update_called.append("update")
27
+ return self
28
+
29
+ def eq(self, *args):
30
+ return self
31
+
32
+ def execute(self):
33
+ return None
34
+
35
+ class FakeClient:
36
+ def table(self, name):
37
+ return FakeTable()
38
+
39
+ client = FakeClient()
40
+ mod._local_owned_locks.clear()
41
+
42
+ mod._handle_multi_session_lock(client, "src/multi.py", "old-token")
43
+
44
+ assert "update" in update_called
45
+ assert "src/multi.py" in mod._local_owned_locks
46
+
47
+
48
+ def test_handle_multi_session_interactive_release_choice_3(monkeypatch):
49
+ """Test interactive multi-session lock release (choice 3)."""
50
+ mod = load_watcher_module()
51
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
52
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
53
+
54
+ import builtins
55
+
56
+ monkeypatch.setattr(builtins, "input", lambda p: "3")
57
+
58
+ delete_called = []
59
+
60
+ class FakeTable:
61
+ def delete(self):
62
+ delete_called.append("delete")
63
+ return self
64
+
65
+ def eq(self, *args):
66
+ return self
67
+
68
+ def execute(self):
69
+ return None
70
+
71
+ class FakeClient:
72
+ def table(self, name):
73
+ return FakeTable()
74
+
75
+ client = FakeClient()
76
+ mod._local_owned_locks.clear()
77
+
78
+ mod._handle_multi_session_lock(client, "src/multi.py", "old-token")
79
+
80
+ assert "delete" in delete_called
81
+ assert "src/multi.py" not in mod._local_owned_locks
82
+
83
+
84
+ def test_handle_multi_session_interactive_leave_choice_2(monkeypatch):
85
+ """Test interactive multi-session lock leave (choice 2)."""
86
+ mod = load_watcher_module()
87
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
88
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
89
+
90
+ import builtins
91
+
92
+ monkeypatch.setattr(builtins, "input", lambda p: "2")
93
+
94
+ touched_db = []
95
+
96
+ class FakeTable:
97
+ def update(self, *args):
98
+ touched_db.append(True)
99
+ return self
100
+
101
+ def delete(self):
102
+ touched_db.append(True)
103
+ return self
104
+
105
+ def eq(self, *args):
106
+ return self
107
+
108
+ def execute(self):
109
+ return None
110
+
111
+ class FakeClient:
112
+ def table(self, name):
113
+ return FakeTable()
114
+
115
+ client = FakeClient()
116
+ mod._local_owned_locks.clear()
117
+
118
+ mod._handle_multi_session_lock(client, "src/multi.py", "old-token")
119
+
120
+ assert not touched_db
121
+ assert "src/multi.py" not in mod._local_owned_locks
122
+
123
+
124
+ def test_handle_post_restart_conflict_interactive_abort_choice_4(monkeypatch):
125
+ """Test interactive post-restart conflict aborts on choice 4."""
126
+ mod = load_watcher_module()
127
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
128
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
129
+
130
+ inputs = iter(["4"])
131
+ import builtins
132
+
133
+ monkeypatch.setattr(builtins, "input", lambda p: next(inputs))
134
+
135
+ exit_called = []
136
+
137
+ def mock_exit(code):
138
+ exit_called.append(code)
139
+ raise SystemExit(code)
140
+
141
+ monkeypatch.setattr(sys, "exit", mock_exit)
142
+
143
+ shutdown_called = []
144
+ monkeypatch.setattr(mod, "_graceful_shutdown", lambda: shutdown_called.append(True))
145
+ monkeypatch.setattr(mod, "_notify", lambda t, m: None)
146
+
147
+ mod._active_conflicts.clear()
148
+
149
+ with pytest.raises(SystemExit):
150
+ mod._handle_post_restart_conflict(None, "src/conflict.py", {"owner": "bob"})
151
+
152
+ assert shutdown_called
153
+ assert exit_called == [1]
154
+
155
+
156
+ def test_handle_post_restart_conflict_interactive_show_diff_then_continue(monkeypatch):
157
+ """Choice 2 shows git diff, then choice 1 continues and tracks conflict."""
158
+ mod = load_watcher_module()
159
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
160
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
161
+
162
+ # First select "show diff", then continue
163
+ inputs = iter(["2", "1"])
164
+ import builtins
165
+
166
+ monkeypatch.setattr(builtins, "input", lambda p: next(inputs))
167
+ monkeypatch.setattr(
168
+ mod.subprocess,
169
+ "check_output",
170
+ lambda *a, **k: b"diff --git a/src/conflict.py b/src/conflict.py\n",
171
+ )
172
+ monkeypatch.setattr(mod, "_notify", lambda t, m: None)
173
+
174
+ mod._active_conflicts.clear()
175
+ mod._handle_post_restart_conflict(
176
+ None,
177
+ "src/conflict.py",
178
+ {"owner": "bob", "branch": "main", "reason": "test"},
179
+ )
180
+
181
+ assert "src/conflict.py" in mod._active_conflicts
182
+
183
+
184
+ def test_handle_post_restart_conflict_interactive_diff_failure_then_continue(
185
+ monkeypatch,
186
+ ):
187
+ """Choice 2 handles git diff failure and still allows continue."""
188
+ mod = load_watcher_module()
189
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
190
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
191
+
192
+ # First select "show diff" (fails), then continue
193
+ inputs = iter(["2", "1"])
194
+ import builtins
195
+
196
+ monkeypatch.setattr(builtins, "input", lambda p: next(inputs))
197
+
198
+ def _boom(*args, **kwargs):
199
+ raise RuntimeError("git diff unavailable")
200
+
201
+ monkeypatch.setattr(mod.subprocess, "check_output", _boom)
202
+ monkeypatch.setattr(mod, "_notify", lambda t, m: None)
203
+
204
+ mod._active_conflicts.clear()
205
+ mod._handle_post_restart_conflict(None, "src/conflict.py", {"owner": "bob"})
206
+
207
+ assert "src/conflict.py" in mod._active_conflicts
208
+
209
+
210
+ def test_handle_post_restart_conflict_tty_input_eof_defaults_continue(monkeypatch):
211
+ """EOF/interrupt in prompt defaults to choice 1 (continue)."""
212
+ mod = load_watcher_module()
213
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
214
+
215
+ import builtins
216
+
217
+ def _raise_eof(prompt):
218
+ raise EOFError()
219
+
220
+ monkeypatch.setattr(builtins, "input", _raise_eof)
221
+ monkeypatch.setattr(mod, "_notify", lambda t, m: None)
222
+
223
+ mod._active_conflicts.clear()
224
+ mod._handle_post_restart_conflict(None, "src/eof_conflict.py", {"owner": "bob"})
225
+
226
+ assert "src/eof_conflict.py" in mod._active_conflicts
227
+
228
+
229
+ def test_handle_multi_session_interactive_eof_defaults_leave(monkeypatch):
230
+ """EOF in interactive prompt should fall back to leave-lock option."""
231
+ mod = load_watcher_module()
232
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
233
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
234
+
235
+ import builtins
236
+
237
+ def _raise_eof(prompt):
238
+ raise EOFError()
239
+
240
+ monkeypatch.setattr(builtins, "input", _raise_eof)
241
+ touched = []
242
+
243
+ class FakeTable:
244
+ def update(self, *args):
245
+ touched.append("update")
246
+ return self
247
+
248
+ def delete(self):
249
+ touched.append("delete")
250
+ return self
251
+
252
+ def eq(self, *args):
253
+ return self
254
+
255
+ def execute(self):
256
+ return None
257
+
258
+ class FakeClient:
259
+ def table(self, name):
260
+ return FakeTable()
261
+
262
+ mod._handle_multi_session_lock(FakeClient(), "src/multi.py", "old-token")
263
+ assert touched == []
264
+
265
+
266
+ def test_handle_multi_session_choice1_update_exception(monkeypatch):
267
+ """Update failure on choice 1 should be caught and still re-adopt locally."""
268
+ mod = load_watcher_module()
269
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
270
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
271
+
272
+ import builtins
273
+
274
+ monkeypatch.setattr(builtins, "input", lambda p: "1")
275
+ mod._local_owned_locks.clear()
276
+
277
+ class FakeTable:
278
+ def update(self, *args):
279
+ return self
280
+
281
+ def eq(self, *args):
282
+ return self
283
+
284
+ def execute(self):
285
+ raise RuntimeError("db down")
286
+
287
+ class FakeClient:
288
+ def table(self, name):
289
+ return FakeTable()
290
+
291
+ mod._handle_multi_session_lock(FakeClient(), "src/err_update.py", "old-token")
292
+ assert "src/err_update.py" in mod._local_owned_locks
293
+
294
+
295
+ def test_handle_multi_session_choice3_delete_exception(monkeypatch):
296
+ """Delete failure on choice 3 should be caught without crashing."""
297
+ mod = load_watcher_module()
298
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
299
+ monkeypatch.setattr(sys, "stdin", type("F", (), {"isatty": lambda s: True})())
300
+
301
+ import builtins
302
+
303
+ monkeypatch.setattr(builtins, "input", lambda p: "3")
304
+
305
+ class FakeTable:
306
+ def delete(self):
307
+ return self
308
+
309
+ def eq(self, *args):
310
+ return self
311
+
312
+ def execute(self):
313
+ raise RuntimeError("db down")
314
+
315
+ class FakeClient:
316
+ def table(self, name):
317
+ return FakeTable()
318
+
319
+ # no raise expected
320
+ mod._handle_multi_session_lock(FakeClient(), "src/err_delete.py", "old-token")
@@ -0,0 +1,67 @@
1
+ """Notification-related tests for live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._helpers import load_watcher_module
6
+
7
+
8
+ def test_notify_uses_desktop_notify_if_available(monkeypatch, caplog):
9
+ mod = load_watcher_module()
10
+ called = {}
11
+
12
+ class FakeDesktop:
13
+ def notify(self, title=None, message=None, app_name=None, timeout=None):
14
+ called["title"] = title
15
+ called["msg"] = message
16
+
17
+ monkeypatch.setattr(mod, "desktop_notify", FakeDesktop())
18
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
19
+ mod._notify("T", "M")
20
+ assert called.get("title") == "T"
21
+ assert "M" in called.get("msg")
22
+
23
+
24
+ def test_notify_with_title_and_message(monkeypatch):
25
+ mod = load_watcher_module()
26
+ notify_called = []
27
+
28
+ def mock_notify(title, message, app_name=None):
29
+ notify_called.append((title, message, app_name))
30
+
31
+ monkeypatch.setattr(mod, "_desktop_notify", mock_notify, raising=False)
32
+
33
+ try:
34
+ mod._notify("Test Title", "Test Message")
35
+ except Exception:
36
+ pass
37
+
38
+ if notify_called:
39
+ assert notify_called[0][0] == "Test Title"
40
+ assert notify_called[0][1] == "Test Message"
41
+
42
+
43
+ def test_notify_fallback_no_desktop_notify(monkeypatch, caplog):
44
+ mod = load_watcher_module()
45
+ monkeypatch.setattr(mod, "desktop_notify", None)
46
+ import logging
47
+
48
+ with caplog.at_level(logging.INFO):
49
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
50
+ mod._notify("Test Title", "Test Message")
51
+ assert "Test Title" in caplog.text or "Test Message" in caplog.text
52
+
53
+
54
+ def test_notify_desktop_notify_exception(monkeypatch, caplog):
55
+ mod = load_watcher_module()
56
+
57
+ class FailingNotify:
58
+ def notify(self, **kwargs):
59
+ raise RuntimeError("notify failed")
60
+
61
+ monkeypatch.setattr(mod, "desktop_notify", FailingNotify())
62
+ import logging
63
+
64
+ with caplog.at_level(logging.INFO):
65
+ monkeypatch.setenv("COLLAB_TEST_MODE", "0")
66
+ mod._notify("Fail Title", "Fail Message")
67
+ assert "Fail Title" in caplog.text or "Fail Message" in caplog.text