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,296 @@
|
|
|
1
|
+
"""Tests for _scan_remote_locks behavior in live_locks_watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._helpers import load_watcher_module
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeScanClient:
|
|
9
|
+
"""Mock Supabase client for _scan_remote_locks tests."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data=None, raise_exc=None):
|
|
12
|
+
self._data = data or []
|
|
13
|
+
self._raise_exc = raise_exc
|
|
14
|
+
|
|
15
|
+
def table(self, *args, **kwargs):
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def select(self, *args, **kwargs):
|
|
19
|
+
if self._raise_exc:
|
|
20
|
+
raise self._raise_exc
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def execute(self):
|
|
24
|
+
if self._raise_exc:
|
|
25
|
+
raise self._raise_exc
|
|
26
|
+
|
|
27
|
+
class Resp:
|
|
28
|
+
data = self._data
|
|
29
|
+
|
|
30
|
+
return Resp()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_scan_remote_locks_warns_about_other_devs(monkeypatch, caplog):
|
|
34
|
+
mod = load_watcher_module()
|
|
35
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
36
|
+
mod._warned_remote_locks.clear()
|
|
37
|
+
monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
|
|
38
|
+
|
|
39
|
+
client = FakeScanClient(
|
|
40
|
+
data=[
|
|
41
|
+
{"file_path": "src/app.py", "developer_id": "bob"},
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
|
|
47
|
+
with caplog.at_level(logging.WARNING, logger="collab.pycharm_watcher"):
|
|
48
|
+
mod._scan_remote_locks(client)
|
|
49
|
+
|
|
50
|
+
assert "src/app.py" in mod._warned_remote_locks
|
|
51
|
+
assert any("REMOTE LOCK" in r.message for r in caplog.records)
|
|
52
|
+
mod._warned_remote_locks.clear()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_scan_remote_locks_skips_own_locks(monkeypatch):
|
|
56
|
+
mod = load_watcher_module()
|
|
57
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
58
|
+
mod._warned_remote_locks.clear()
|
|
59
|
+
|
|
60
|
+
client = FakeScanClient(data=[{"file_path": "src/app.py", "developer_id": "alice"}])
|
|
61
|
+
mod._scan_remote_locks(client)
|
|
62
|
+
|
|
63
|
+
assert "src/app.py" not in mod._warned_remote_locks
|
|
64
|
+
mod._warned_remote_locks.clear()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_scan_remote_locks_clears_released_warnings(monkeypatch, caplog):
|
|
68
|
+
mod = load_watcher_module()
|
|
69
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
70
|
+
mod._warned_remote_locks.clear()
|
|
71
|
+
monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
|
|
72
|
+
|
|
73
|
+
# First scan: bob holds a lock
|
|
74
|
+
client_with_lock = FakeScanClient(
|
|
75
|
+
data=[{"file_path": "src/app.py", "developer_id": "bob"}]
|
|
76
|
+
)
|
|
77
|
+
mod._scan_remote_locks(client_with_lock)
|
|
78
|
+
assert "src/app.py" in mod._warned_remote_locks
|
|
79
|
+
|
|
80
|
+
# Second scan: lock released
|
|
81
|
+
import logging
|
|
82
|
+
|
|
83
|
+
with caplog.at_level(logging.INFO, logger="collab.pycharm_watcher"):
|
|
84
|
+
client_empty = FakeScanClient(data=[])
|
|
85
|
+
mod._scan_remote_locks(client_empty)
|
|
86
|
+
|
|
87
|
+
assert "src/app.py" not in mod._warned_remote_locks
|
|
88
|
+
assert any("Remote lock cleared" in r.message for r in caplog.records)
|
|
89
|
+
mod._warned_remote_locks.clear()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_scan_remote_locks_no_duplicate_warnings(monkeypatch):
|
|
93
|
+
mod = load_watcher_module()
|
|
94
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
95
|
+
mod._warned_remote_locks.clear()
|
|
96
|
+
notify_calls = []
|
|
97
|
+
monkeypatch.setattr(mod, "_notify", lambda t, m: notify_calls.append((t, m)))
|
|
98
|
+
|
|
99
|
+
client = FakeScanClient(data=[{"file_path": "src/app.py", "developer_id": "bob"}])
|
|
100
|
+
|
|
101
|
+
mod._scan_remote_locks(client)
|
|
102
|
+
mod._scan_remote_locks(client) # second call — should NOT notify again
|
|
103
|
+
|
|
104
|
+
assert len(notify_calls) == 1
|
|
105
|
+
mod._warned_remote_locks.clear()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_scan_remote_locks_handles_exception(monkeypatch):
|
|
109
|
+
mod = load_watcher_module()
|
|
110
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
111
|
+
mod._warned_remote_locks.clear()
|
|
112
|
+
|
|
113
|
+
client = FakeScanClient(raise_exc=RuntimeError("network down"))
|
|
114
|
+
mod._scan_remote_locks(client) # should not raise
|
|
115
|
+
|
|
116
|
+
assert len(mod._warned_remote_locks) == 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_scan_remote_locks_skips_empty_file_path(monkeypatch):
|
|
120
|
+
mod = load_watcher_module()
|
|
121
|
+
monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
|
|
122
|
+
mod._warned_remote_locks.clear()
|
|
123
|
+
monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
|
|
124
|
+
|
|
125
|
+
client = FakeScanClient(data=[{"file_path": "", "developer_id": "bob"}])
|
|
126
|
+
mod._scan_remote_locks(client)
|
|
127
|
+
|
|
128
|
+
assert len(mod._warned_remote_locks) == 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_scan_remote_locks_client_exception(monkeypatch):
|
|
132
|
+
mod = load_watcher_module()
|
|
133
|
+
|
|
134
|
+
class FakeResp:
|
|
135
|
+
def __init__(self, data=None):
|
|
136
|
+
self.data = data
|
|
137
|
+
|
|
138
|
+
class FakeClient:
|
|
139
|
+
def __init__(self, data=None, explode=False):
|
|
140
|
+
self._data = data
|
|
141
|
+
self._explode = explode
|
|
142
|
+
|
|
143
|
+
def table(self, *a, **k):
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def select(self, *a, **k):
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def execute(self):
|
|
150
|
+
if self._explode:
|
|
151
|
+
raise RuntimeError("backend down")
|
|
152
|
+
return FakeResp(self._data)
|
|
153
|
+
|
|
154
|
+
fake = FakeClient(explode=True)
|
|
155
|
+
mod._warned_remote_locks.clear()
|
|
156
|
+
mod._known_remote_locks.clear()
|
|
157
|
+
mod._scan_remote_locks(fake)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_scan_remote_locks_warns_for_other_owner(monkeypatch):
|
|
161
|
+
mod = load_watcher_module()
|
|
162
|
+
fake_data = [
|
|
163
|
+
{
|
|
164
|
+
"developer_id": "other_user",
|
|
165
|
+
"file_path": "src/locked.txt",
|
|
166
|
+
"branch_name": None,
|
|
167
|
+
"reason": None,
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
fake = type(
|
|
171
|
+
"C",
|
|
172
|
+
(),
|
|
173
|
+
{
|
|
174
|
+
"_data": fake_data,
|
|
175
|
+
"table": lambda self, *a, **k: self,
|
|
176
|
+
"select": lambda self, *a, **k: self,
|
|
177
|
+
"execute": lambda self: type("R", (), {"data": self._data})(),
|
|
178
|
+
},
|
|
179
|
+
)()
|
|
180
|
+
mod.DEVELOPER_ID = "me"
|
|
181
|
+
mod._warned_remote_locks.clear()
|
|
182
|
+
mod._known_remote_locks.clear()
|
|
183
|
+
mod._scan_remote_locks(fake)
|
|
184
|
+
assert "src/locked.txt" in mod._warned_remote_locks
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_scan_remote_locks_same_owner_updates_known():
|
|
188
|
+
mod = load_watcher_module()
|
|
189
|
+
fake_data = [
|
|
190
|
+
{
|
|
191
|
+
"developer_id": "me",
|
|
192
|
+
"file_path": "src/mine.txt",
|
|
193
|
+
"branch_name": None,
|
|
194
|
+
"reason": None,
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
class FakeClient3:
|
|
199
|
+
def __init__(self, data):
|
|
200
|
+
self._data = data
|
|
201
|
+
|
|
202
|
+
def table(self, *a, **k):
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def select(self, *a, **k):
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def execute(self):
|
|
209
|
+
class R:
|
|
210
|
+
data = self._data
|
|
211
|
+
|
|
212
|
+
return R()
|
|
213
|
+
|
|
214
|
+
fake = FakeClient3(fake_data)
|
|
215
|
+
mod.DEVELOPER_ID = "me"
|
|
216
|
+
mod._known_remote_locks.clear()
|
|
217
|
+
mod._warned_remote_locks.clear()
|
|
218
|
+
mod._scan_remote_locks(fake)
|
|
219
|
+
assert "src/mine.txt" in mod._known_remote_locks
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---- Auto-migrated from migrated_remaining ----
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_scan_remote_locks_skips_local_owned(monkeypatch):
|
|
226
|
+
mod = load_watcher_module()
|
|
227
|
+
# If a remote lock entry belongs to our developer and we already have it in
|
|
228
|
+
# _local_owned_locks, the scan should skip notifications for it.
|
|
229
|
+
fake_data = [
|
|
230
|
+
{
|
|
231
|
+
"developer_id": "me",
|
|
232
|
+
"file_path": "src/owned.txt",
|
|
233
|
+
"branch_name": None,
|
|
234
|
+
"reason": None,
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
class FakeClientLocal:
|
|
239
|
+
def __init__(self, data=None):
|
|
240
|
+
self._data = data
|
|
241
|
+
|
|
242
|
+
def table(self, *a, **k):
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def select(self, *a, **k):
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
def execute(self):
|
|
249
|
+
class R:
|
|
250
|
+
data = self._data
|
|
251
|
+
|
|
252
|
+
return R()
|
|
253
|
+
|
|
254
|
+
fake = FakeClientLocal(data=fake_data)
|
|
255
|
+
mod.DEVELOPER_ID = "me"
|
|
256
|
+
mod._local_owned_locks.clear()
|
|
257
|
+
mod._local_owned_locks.add("src/owned.txt")
|
|
258
|
+
mod._warned_remote_locks.clear()
|
|
259
|
+
mod._known_remote_locks.clear()
|
|
260
|
+
|
|
261
|
+
# Should not raise and should not add to _warned_remote_locks
|
|
262
|
+
mod._scan_remote_locks(fake)
|
|
263
|
+
assert "src/owned.txt" not in mod._warned_remote_locks
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_scan_remote_locks_removed_discards_local_owned(monkeypatch):
|
|
267
|
+
mod = load_watcher_module()
|
|
268
|
+
# Simulate a previously-known remote lock that was released; if we had it
|
|
269
|
+
# recorded locally, the code path should discard it from _local_owned_locks.
|
|
270
|
+
mod._known_remote_locks.clear()
|
|
271
|
+
mod._known_remote_locks.add("src/released.txt")
|
|
272
|
+
mod._local_owned_locks.clear()
|
|
273
|
+
mod._local_owned_locks.add("src/released.txt")
|
|
274
|
+
|
|
275
|
+
# Fake client returns no locks (empty list)
|
|
276
|
+
class EmptyClient:
|
|
277
|
+
def table(self, *a, **k):
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
def select(self, *a, **k):
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
def execute(self):
|
|
284
|
+
class R:
|
|
285
|
+
data = []
|
|
286
|
+
|
|
287
|
+
return R()
|
|
288
|
+
|
|
289
|
+
fake = EmptyClient()
|
|
290
|
+
|
|
291
|
+
mod._scan_remote_locks(fake)
|
|
292
|
+
# After scanning, released lock should be removed from local-owned set
|
|
293
|
+
assert "src/released.txt" not in mod._local_owned_locks
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
watcher = load_watcher_module()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# lock_client test package
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared helpers for LockClient tests in the lock_client test package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
# Ensure supabase is mocked so importing the module doesn't raise if supabase
|
|
12
|
+
# isn't installed in the test environment.
|
|
13
|
+
sys.modules.setdefault("supabase", mock.MagicMock())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_lock_client_path() -> Path:
|
|
17
|
+
p = Path(__file__).resolve()
|
|
18
|
+
for parent in p.parents:
|
|
19
|
+
candidate = parent / "src" / "lock_client.py"
|
|
20
|
+
if candidate.exists():
|
|
21
|
+
return candidate
|
|
22
|
+
candidate = Path(__file__).resolve().parents[4] / "src" / "lock_client.py"
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
raise FileNotFoundError("lock_client.py not found in repo")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_lock_client_module():
|
|
29
|
+
candidate = _find_lock_client_path()
|
|
30
|
+
repo_root = candidate.parents[2]
|
|
31
|
+
root_str = str(repo_root)
|
|
32
|
+
if root_str not in sys.path:
|
|
33
|
+
sys.path.insert(0, root_str)
|
|
34
|
+
|
|
35
|
+
# Always import via package machinery so src.main and tests share the
|
|
36
|
+
# exact same module object for monkeypatching.
|
|
37
|
+
if "src.lock_client" in sys.modules:
|
|
38
|
+
return sys.modules["src.lock_client"]
|
|
39
|
+
return importlib.import_module("src.lock_client")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_lock_client_module():
|
|
43
|
+
"""Backward-compatible alias for older tests that call `_load_lock_client_module()`.
|
|
44
|
+
|
|
45
|
+
Prefer `load_lock_client_module()` in new tests.
|
|
46
|
+
"""
|
|
47
|
+
return load_lock_client_module()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_watcher_path() -> Path:
|
|
51
|
+
p = Path(__file__).resolve()
|
|
52
|
+
for parent in p.parents:
|
|
53
|
+
candidate = parent / "src" / "live_locks_watcher.py"
|
|
54
|
+
if candidate.exists():
|
|
55
|
+
return candidate
|
|
56
|
+
candidate = Path(__file__).resolve().parents[5] / "src" / "live_locks_watcher.py"
|
|
57
|
+
if candidate.exists():
|
|
58
|
+
return candidate
|
|
59
|
+
raise FileNotFoundError("live_locks_watcher.py not found in repo")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_watcher_module():
|
|
63
|
+
candidate = _find_watcher_path()
|
|
64
|
+
spec = importlib.util.spec_from_file_location(
|
|
65
|
+
"src.live_locks_watcher", str(candidate)
|
|
66
|
+
)
|
|
67
|
+
mod = importlib.util.module_from_spec(spec)
|
|
68
|
+
assert spec and spec.loader
|
|
69
|
+
spec.loader.exec_module(mod) # type: ignore[arg-type]
|
|
70
|
+
return mod
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FakeResponse:
|
|
74
|
+
def __init__(self, status=200, data=None, error=None):
|
|
75
|
+
self.status = status
|
|
76
|
+
self.data = data if data is not None else []
|
|
77
|
+
self.error = error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FakeClient:
|
|
81
|
+
def __init__(self, resp):
|
|
82
|
+
self._resp = resp
|
|
83
|
+
|
|
84
|
+
def rpc(self, *a, **k):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def table(self, *a, **k):
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def select(self, *a, **k):
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def delete(self, *a, **k):
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def eq(self, *a, **k):
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
def insert(self, *a, **k):
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def update(self, *a, **k):
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def order(self, *a, **k):
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def limit(self, *a, **k):
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def ilike(self, *a, **k):
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def execute(self):
|
|
115
|
+
return self._resp
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_create_client(resp):
|
|
119
|
+
def fake_create(url, key):
|
|
120
|
+
return FakeClient(resp)
|
|
121
|
+
|
|
122
|
+
return fake_create
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def make_get_create_client(resp):
|
|
126
|
+
def fake_get_create_client():
|
|
127
|
+
def create_client(url, key):
|
|
128
|
+
return FakeClient(resp)
|
|
129
|
+
|
|
130
|
+
return create_client
|
|
131
|
+
|
|
132
|
+
return fake_get_create_client
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Acquire-related tests for LockClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._helpers import (
|
|
6
|
+
FakeClient,
|
|
7
|
+
FakeResponse,
|
|
8
|
+
load_lock_client_module,
|
|
9
|
+
make_create_client,
|
|
10
|
+
make_get_create_client,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
mod = load_lock_client_module()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_acquire_multiple_success(monkeypatch, tmp_path):
|
|
17
|
+
"""Test batch lock acquisition."""
|
|
18
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
19
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
20
|
+
|
|
21
|
+
file1 = tmp_path / "src" / "app.py"
|
|
22
|
+
file2 = tmp_path / "src" / "routes.py"
|
|
23
|
+
file1.parent.mkdir(parents=True)
|
|
24
|
+
file1.write_text("# code")
|
|
25
|
+
file2.write_text("# code")
|
|
26
|
+
|
|
27
|
+
response = FakeResponse(status=200, data=[{"status": "ok", "lock_id": "batch123"}])
|
|
28
|
+
monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
|
|
29
|
+
|
|
30
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
31
|
+
ok, failed, msg = lc.acquire_multiple([str(file1), str(file2)])
|
|
32
|
+
assert isinstance(ok, bool)
|
|
33
|
+
assert isinstance(failed, list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# RESTORED: test_acquire_and_release_ephemeral (migrated from additional)
|
|
37
|
+
def test_acquire_and_release_ephemeral(tmp_path, monkeypatch):
|
|
38
|
+
# Create a temp file to acquire
|
|
39
|
+
f = tmp_path / "file.txt"
|
|
40
|
+
f.write_text("x")
|
|
41
|
+
|
|
42
|
+
client = object.__new__(mod.LockClient)
|
|
43
|
+
# Simulate ephemeral developer
|
|
44
|
+
client.developer_id = "test_dev_123"
|
|
45
|
+
client._is_ephemeral = True
|
|
46
|
+
|
|
47
|
+
ok, token = mod.LockClient.acquire(client, str(f))
|
|
48
|
+
assert ok and token.startswith("ephemeral-")
|
|
49
|
+
|
|
50
|
+
ok_rel, msg = mod.LockClient.release(client, str(f))
|
|
51
|
+
assert ok_rel and "ephemeral" in msg
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# RESTORED: test_acquire_api_exception (migrated from monolith)
|
|
55
|
+
def test_acquire_api_exception(monkeypatch, tmp_path):
|
|
56
|
+
"""Test acquire when API call raises an exception."""
|
|
57
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
58
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
59
|
+
|
|
60
|
+
test_file = tmp_path / "src" / "app.py"
|
|
61
|
+
test_file.parent.mkdir(parents=True)
|
|
62
|
+
test_file.write_text("# code")
|
|
63
|
+
|
|
64
|
+
class ExplodingClient(FakeClient):
|
|
65
|
+
def execute(self):
|
|
66
|
+
raise RuntimeError("API exploded")
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr(
|
|
69
|
+
mod,
|
|
70
|
+
"_get_create_client",
|
|
71
|
+
lambda: lambda url, key: ExplodingClient(FakeResponse()),
|
|
72
|
+
)
|
|
73
|
+
monkeypatch.setattr(mod, "_PROJECT_ROOT", str(tmp_path))
|
|
74
|
+
|
|
75
|
+
lc = mod.LockClient(developer_id="test_user")
|
|
76
|
+
ok, msg = lc.acquire(str(test_file))
|
|
77
|
+
assert ok is False
|
|
78
|
+
assert "API Error" in msg
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# RESTORED: test_acquire_missing_file_returns_false (migrated from monolith)
|
|
82
|
+
def test_acquire_missing_file_returns_false(monkeypatch):
|
|
83
|
+
monkeypatch.setenv("SUPABASE_URL", "https://example.invalid")
|
|
84
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon:fake")
|
|
85
|
+
monkeypatch.setattr(
|
|
86
|
+
mod,
|
|
87
|
+
"_get_create_client",
|
|
88
|
+
make_get_create_client({"status": 200, "data": []}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
lc = mod.LockClient(developer_id="tester")
|
|
92
|
+
ok, msg = lc.acquire("this/path/does/not/exist.file")
|
|
93
|
+
assert not ok
|
|
94
|
+
assert "does not exist" in msg
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_acquire_missing_file_allowed_when_in_progress(monkeypatch):
|
|
98
|
+
"""Deleted paths remain lockable when git marks them in progress."""
|
|
99
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
100
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
101
|
+
|
|
102
|
+
response = FakeResponse(status=200, data=[{"status": "ok"}])
|
|
103
|
+
monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
|
|
104
|
+
|
|
105
|
+
lc = mod.LockClient(developer_id="tester")
|
|
106
|
+
target = ".github/workflows/validate-on-pr.yml"
|
|
107
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [target])
|
|
108
|
+
|
|
109
|
+
ok, token = lc.acquire(target)
|
|
110
|
+
assert ok is True
|
|
111
|
+
assert isinstance(token, str) and len(token) > 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_acquire_missing_file_in_progress_lookup_error(monkeypatch):
|
|
115
|
+
"""If in-progress detection fails, missing paths are rejected safely."""
|
|
116
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
117
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
118
|
+
monkeypatch.setattr(
|
|
119
|
+
mod,
|
|
120
|
+
"_get_create_client",
|
|
121
|
+
make_get_create_client({"status": 200, "data": []}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
lc = mod.LockClient(developer_id="tester")
|
|
125
|
+
|
|
126
|
+
def _boom():
|
|
127
|
+
raise RuntimeError("git scan failed")
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", _boom)
|
|
130
|
+
ok, msg = lc.acquire("deleted/path.py")
|
|
131
|
+
assert ok is False
|
|
132
|
+
assert "does not exist" in msg
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_acquire_directory_returns_false(monkeypatch, tmp_path):
|
|
136
|
+
"""Directory paths are not lockable."""
|
|
137
|
+
monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
|
|
138
|
+
monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
|
|
139
|
+
monkeypatch.setattr(
|
|
140
|
+
mod, "_get_create_client", make_get_create_client({"status": 200, "data": []})
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
instance_dir = tmp_path / "apps" / "reporting" / "instance"
|
|
144
|
+
instance_dir.mkdir(parents=True)
|
|
145
|
+
monkeypatch.setattr(mod, "_PROJECT_ROOT", str(tmp_path))
|
|
146
|
+
|
|
147
|
+
lc = mod.LockClient(developer_id="tester")
|
|
148
|
+
ok, msg = lc.acquire("apps/reporting/instance")
|
|
149
|
+
assert ok is False
|
|
150
|
+
assert "directory" in msg.lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_acquire_ephemeral(tmp_path):
|
|
154
|
+
"""Acquire a file with ephemeral developer ID (local_only)."""
|
|
155
|
+
mod = load_lock_client_module()
|
|
156
|
+
f = tmp_path / "a.txt"
|
|
157
|
+
f.write_text("x")
|
|
158
|
+
client = mod.LockClient(local_only=True, developer_id="test_dev_joe")
|
|
159
|
+
ok, token = client.acquire(str(f))
|
|
160
|
+
assert ok is True
|
|
161
|
+
assert isinstance(token, str) and token.startswith("ephemeral-")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_acquire_success_and_conflict(tmp_path):
|
|
165
|
+
"""Acquire success and conflict scenarios via supabase client."""
|
|
166
|
+
mod = load_lock_client_module()
|
|
167
|
+
f = tmp_path / "b.txt"
|
|
168
|
+
f.write_text("x")
|
|
169
|
+
|
|
170
|
+
# Success
|
|
171
|
+
client = mod.LockClient(local_only=True, developer_id="alice")
|
|
172
|
+
client._client = FakeClient(FakeResponse(status=200, data=[{"status": "ok"}]))
|
|
173
|
+
ok, token = client.acquire(str(f))
|
|
174
|
+
assert ok is True
|
|
175
|
+
assert isinstance(token, str) and len(token) == 16
|
|
176
|
+
|
|
177
|
+
# Conflict
|
|
178
|
+
client2 = mod.LockClient(local_only=True, developer_id="bob")
|
|
179
|
+
client2._client = FakeClient(
|
|
180
|
+
FakeResponse(status=200, data=[{"status": "conflict", "owner": "eve"}])
|
|
181
|
+
)
|
|
182
|
+
ok2, msg2 = client2.acquire(str(f))
|
|
183
|
+
assert ok2 is False
|
|
184
|
+
assert "@eve" in msg2
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_acquire_api_error(tmp_path):
|
|
188
|
+
"""Acquire when supabase returns a 500 error."""
|
|
189
|
+
mod = load_lock_client_module()
|
|
190
|
+
f = tmp_path / "c.txt"
|
|
191
|
+
f.write_text("x")
|
|
192
|
+
client = mod.LockClient(local_only=True, developer_id="carol")
|
|
193
|
+
client._client = FakeClient(
|
|
194
|
+
FakeResponse(status=500, data=None, error={"message": "rpc fail"})
|
|
195
|
+
)
|
|
196
|
+
ok, msg = client.acquire(str(f))
|
|
197
|
+
assert ok is False
|
|
198
|
+
assert "API Error" in msg
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_acquire_unexpected_response(tmp_path):
|
|
202
|
+
"""Cover line 738 unexpected-response path.
|
|
203
|
+
|
|
204
|
+
acquire returns "Unexpected response" when status is not 200/201 and data is empty.
|
|
205
|
+
"""
|
|
206
|
+
mod = load_lock_client_module()
|
|
207
|
+
f = tmp_path / "u.txt"
|
|
208
|
+
f.write_text("x")
|
|
209
|
+
client = mod.LockClient(local_only=True, developer_id="user1")
|
|
210
|
+
# Return empty data list and non-200/201 status to trigger line 738
|
|
211
|
+
client._client = FakeClient(FakeResponse(status=503, data=[], error=None))
|
|
212
|
+
ok, msg = client.acquire(str(f))
|
|
213
|
+
assert ok is False
|
|
214
|
+
assert "Unexpected response" in msg
|