izteamslots 1.6.0 → 1.6.1
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.
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +20 -8
- package/backend/rpc_server.py +13 -0
- package/backend/slot_orchestrator.py +100 -0
- package/backend/ui_facade.py +12 -0
- package/package.json +1 -1
- package/tests/test_account_store.py +86 -0
- package/tests/test_dto.py +43 -0
- package/tests/test_jobs.py +76 -0
- package/tests/test_rpc_protocol.py +33 -0
- package/tests/test_slot_orchestrator.py +115 -0
- package/ui/package.json +2 -1
- package/ui/src/menus/mainMenus.ts +1 -0
- package/ui/src/menus/types.ts +6 -0
- package/ui/src/screens/MainScreen.ts +98 -0
- package/ui/tests/mainMenus.test.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.6.1](https://github.com/izzzzzi/izTeamSlots/compare/v1.6.0...v1.6.1) (2026-03-07)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **ci:** install python deps before unit tests ([5c57d2a](https://github.com/izzzzzi/izTeamSlots/commit/5c57d2a5c9f22ed667d9cbdedacb69c465d136ba))
|
|
7
|
+
|
|
1
8
|
# [1.6.0](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.5...v1.6.0) (2026-03-06)
|
|
2
9
|
|
|
3
10
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -42,19 +42,30 @@ izTeamSlots/
|
|
|
42
42
|
└── .github/workflows/ # CI/CD
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
##
|
|
45
|
+
## Проверки и тесты
|
|
46
46
|
|
|
47
47
|
Перед коммитом убедитесь что код проходит проверки:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
# Python —
|
|
51
|
-
ruff check backend
|
|
50
|
+
# Python — lint + unit tests
|
|
51
|
+
ruff check backend tests
|
|
52
|
+
python -m unittest discover -s tests -p 'test_*.py'
|
|
52
53
|
|
|
53
|
-
# TypeScript —
|
|
54
|
-
npm run typecheck
|
|
54
|
+
# TypeScript — typecheck + unit tests
|
|
55
|
+
npm --prefix ui run typecheck
|
|
56
|
+
npm --prefix ui run test
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
CI автоматически запускает
|
|
59
|
+
CI автоматически запускает эти проверки на каждый PR.
|
|
60
|
+
|
|
61
|
+
## Требование по тестам
|
|
62
|
+
|
|
63
|
+
- Любая новая функциональность или заметное изменение логики должно сопровождаться тестами.
|
|
64
|
+
- Минимум: покрывайте тот слой, который можно проверить локально без браузерного e2e.
|
|
65
|
+
- Для Python-логики добавляйте `unittest`-тесты в `tests/`.
|
|
66
|
+
- Для чистой TypeScript-логики добавляйте тесты в `ui/tests/`.
|
|
67
|
+
- Если изменение нельзя адекватно покрыть unit-тестом, это нужно явно отметить в описании PR.
|
|
68
|
+
- PR без тестов для новой логики может быть отклонён.
|
|
58
69
|
|
|
59
70
|
## Conventional Commits
|
|
60
71
|
|
|
@@ -100,8 +111,9 @@ chore: update seleniumbase to 4.33
|
|
|
100
111
|
|
|
101
112
|
1. Один PR — одно логическое изменение.
|
|
102
113
|
2. Следуйте Conventional Commits.
|
|
103
|
-
3.
|
|
104
|
-
4.
|
|
114
|
+
3. Добавьте или обновите тесты, если меняется логика приложения.
|
|
115
|
+
4. Убедитесь что lint, unit tests и typecheck проходят локально.
|
|
116
|
+
5. Обновите документацию если изменение затрагивает пользовательское поведение.
|
|
105
117
|
|
|
106
118
|
### Новый почтовый провайдер
|
|
107
119
|
|
package/backend/rpc_server.py
CHANGED
|
@@ -155,6 +155,19 @@ class RPCServer:
|
|
|
155
155
|
)
|
|
156
156
|
return make_success_response(req.request_id, {"job_id": job_id})
|
|
157
157
|
|
|
158
|
+
if m == "workspace.sync_preview":
|
|
159
|
+
admin_email = self._as_str_param(p, "admin_email")
|
|
160
|
+
result = self.facade.preview_workspace_sync(admin_email)
|
|
161
|
+
return make_success_response(req.request_id, result)
|
|
162
|
+
|
|
163
|
+
if m == "job.sync_workspace":
|
|
164
|
+
admin_email = self._as_str_param(p, "admin_email")
|
|
165
|
+
job_id = self._run_job(
|
|
166
|
+
f"Синхронизация WS: {admin_email}",
|
|
167
|
+
lambda ctx: self.facade.sync_workspace(admin_email, ctx.log),
|
|
168
|
+
)
|
|
169
|
+
return make_success_response(req.request_id, {"job_id": job_id})
|
|
170
|
+
|
|
158
171
|
if m == "job.open_admin_browser":
|
|
159
172
|
email = self._as_str_param(p, "email")
|
|
160
173
|
job_id = self._run_job(
|
|
@@ -310,6 +310,106 @@ class SlotManager:
|
|
|
310
310
|
api = self._get_api(page)
|
|
311
311
|
return api.get_members()
|
|
312
312
|
|
|
313
|
+
def _build_workspace_sync_plan(
|
|
314
|
+
self,
|
|
315
|
+
members: list[dict[str, Any]],
|
|
316
|
+
pending_invites: list[dict[str, Any]],
|
|
317
|
+
) -> dict[str, Any]:
|
|
318
|
+
local_emails = {w.email.strip().lower() for w in self._get_workers() if w.email}
|
|
319
|
+
admin_email = (self.admin_email or "").strip().lower()
|
|
320
|
+
|
|
321
|
+
extra_members: list[dict[str, str]] = []
|
|
322
|
+
extra_invites: list[dict[str, str]] = []
|
|
323
|
+
skipped: list[dict[str, str]] = []
|
|
324
|
+
|
|
325
|
+
for member in members:
|
|
326
|
+
email = str(member.get("email") or "").strip()
|
|
327
|
+
if not email:
|
|
328
|
+
continue
|
|
329
|
+
email_key = email.lower()
|
|
330
|
+
role = str(member.get("role") or "")
|
|
331
|
+
|
|
332
|
+
if email_key == admin_email:
|
|
333
|
+
skipped.append({"type": "member", "email": email, "reason": "self"})
|
|
334
|
+
continue
|
|
335
|
+
if role and role != "standard-user":
|
|
336
|
+
skipped.append({"type": "member", "email": email, "reason": f"role:{role}"})
|
|
337
|
+
continue
|
|
338
|
+
if email_key in local_emails:
|
|
339
|
+
continue
|
|
340
|
+
|
|
341
|
+
extra_members.append(
|
|
342
|
+
{
|
|
343
|
+
"email": email,
|
|
344
|
+
"id": str(member.get("id") or ""),
|
|
345
|
+
"role": role or "standard-user",
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
for invite in pending_invites:
|
|
350
|
+
email = str(invite.get("email") or invite.get("email_address") or "").strip()
|
|
351
|
+
if not email:
|
|
352
|
+
continue
|
|
353
|
+
email_key = email.lower()
|
|
354
|
+
if email_key == admin_email:
|
|
355
|
+
skipped.append({"type": "invite", "email": email, "reason": "self"})
|
|
356
|
+
continue
|
|
357
|
+
if email_key in local_emails:
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
extra_invites.append({"email": email})
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
"admin_email": self.admin_email,
|
|
364
|
+
"workspace_id": self.account_id,
|
|
365
|
+
"local_workers_total": len(local_emails),
|
|
366
|
+
"members_total": len(members),
|
|
367
|
+
"pending_invites_total": len(pending_invites),
|
|
368
|
+
"extra_members": extra_members,
|
|
369
|
+
"extra_invites": extra_invites,
|
|
370
|
+
"skipped": skipped,
|
|
371
|
+
"removed_members": [],
|
|
372
|
+
"removed_invites": [],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
def sync_workspace(self, *, dry_run: bool = True) -> dict[str, Any]:
|
|
376
|
+
page = self._ensure_admin_page()
|
|
377
|
+
api = self._get_api(page)
|
|
378
|
+
|
|
379
|
+
members = api.get_members()
|
|
380
|
+
pending_invites = api.get_pending_invites()
|
|
381
|
+
result = self._build_workspace_sync_plan(members, pending_invites)
|
|
382
|
+
result["dry_run"] = dry_run
|
|
383
|
+
|
|
384
|
+
if dry_run:
|
|
385
|
+
self._log(f"WS preview: лишних участников {len(result['extra_members'])}, лишних инвайтов {len(result['extra_invites'])}")
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
for member in result["extra_members"]:
|
|
389
|
+
member_id = member.get("id")
|
|
390
|
+
email = member.get("email", "")
|
|
391
|
+
if not member_id:
|
|
392
|
+
self._log(f"[предупреждение] Пропускаю участника без id: {email}")
|
|
393
|
+
continue
|
|
394
|
+
api.delete_member(member_id)
|
|
395
|
+
result["removed_members"].append(email)
|
|
396
|
+
self._log(f"Удалён участник WS: {email}")
|
|
397
|
+
|
|
398
|
+
for invite in result["extra_invites"]:
|
|
399
|
+
email = invite.get("email", "")
|
|
400
|
+
if not email:
|
|
401
|
+
continue
|
|
402
|
+
api.delete_invite(email)
|
|
403
|
+
result["removed_invites"].append(email)
|
|
404
|
+
self._log(f"Удалён инвайт WS: {email}")
|
|
405
|
+
|
|
406
|
+
self._log(
|
|
407
|
+
"WS синхронизирован: "
|
|
408
|
+
f"участников удалено {len(result['removed_members'])}, "
|
|
409
|
+
f"инвайтов удалено {len(result['removed_invites'])}"
|
|
410
|
+
)
|
|
411
|
+
return result
|
|
412
|
+
|
|
313
413
|
def register_slot(self, worker: WorkerAccount, invite_url: str) -> None:
|
|
314
414
|
"""Зарегистрировать приглашённый аккаунт через инвайт-ссылку."""
|
|
315
415
|
mailbox = self._mailboxes.get(worker.email)
|
package/backend/ui_facade.py
CHANGED
|
@@ -209,6 +209,18 @@ class UIFacade:
|
|
|
209
209
|
manager.login_admin_manual()
|
|
210
210
|
self.sync_codex_files()
|
|
211
211
|
|
|
212
|
+
def preview_workspace_sync(self, admin_email: str) -> dict[str, Any]:
|
|
213
|
+
manager = SlotManager(store=self.store, admin_email=admin_email, log=lambda _msg: None, headless=True)
|
|
214
|
+
try:
|
|
215
|
+
return manager.sync_workspace(dry_run=True)
|
|
216
|
+
finally:
|
|
217
|
+
manager.close()
|
|
218
|
+
|
|
219
|
+
def sync_workspace(self, admin_email: str, log: LogFunc) -> dict[str, Any]:
|
|
220
|
+
manager = SlotManager(store=self.store, admin_email=admin_email, log=log, headless=False)
|
|
221
|
+
self._replace_manager(manager)
|
|
222
|
+
return manager.sync_workspace(dry_run=False)
|
|
223
|
+
|
|
212
224
|
def run_slots_pipeline(
|
|
213
225
|
self,
|
|
214
226
|
admin_email: str,
|
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from backend.account_store import AccountStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestAccountStore(unittest.TestCase):
|
|
12
|
+
def setUp(self) -> None:
|
|
13
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
14
|
+
self.base_dir = Path(self.temp_dir.name) / "accounts"
|
|
15
|
+
self.store = AccountStore(self.base_dir)
|
|
16
|
+
|
|
17
|
+
def tearDown(self) -> None:
|
|
18
|
+
self.temp_dir.cleanup()
|
|
19
|
+
|
|
20
|
+
def test_admin_crud_roundtrip(self) -> None:
|
|
21
|
+
admin = self.store.add_admin("admin@example.com", "pw")
|
|
22
|
+
admin.access_token = "token"
|
|
23
|
+
admin.workspace_id = "ws-1"
|
|
24
|
+
admin.account_id = "acc-1"
|
|
25
|
+
admin.workspaces = [{"workspace_id": "ws-1"}]
|
|
26
|
+
admin.last_login = "2026-03-07T00:00:00Z"
|
|
27
|
+
self.store.update_admin(admin)
|
|
28
|
+
|
|
29
|
+
loaded = self.store.get_admin("admin@example.com")
|
|
30
|
+
|
|
31
|
+
self.assertIsNotNone(loaded)
|
|
32
|
+
assert loaded is not None
|
|
33
|
+
self.assertEqual(loaded.access_token, "token")
|
|
34
|
+
self.assertEqual(loaded.workspace_id, "ws-1")
|
|
35
|
+
self.assertEqual(loaded.account_id, "acc-1")
|
|
36
|
+
self.assertEqual(loaded.workspaces, [{"workspace_id": "ws-1"}])
|
|
37
|
+
self.assertEqual(loaded.last_login, "2026-03-07T00:00:00Z")
|
|
38
|
+
|
|
39
|
+
self.store.delete_admin("admin@example.com")
|
|
40
|
+
self.assertIsNone(self.store.get_admin("admin@example.com"))
|
|
41
|
+
|
|
42
|
+
def test_worker_crud_roundtrip(self) -> None:
|
|
43
|
+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
|
|
44
|
+
worker.status = "registered"
|
|
45
|
+
worker.openai_password = "openai-pw"
|
|
46
|
+
worker.access_token = "token"
|
|
47
|
+
worker.workspace_id = "ws-1"
|
|
48
|
+
self.store.update_worker(worker)
|
|
49
|
+
|
|
50
|
+
loaded = self.store.get_worker("slot@example.com")
|
|
51
|
+
|
|
52
|
+
self.assertIsNotNone(loaded)
|
|
53
|
+
assert loaded is not None
|
|
54
|
+
self.assertEqual(loaded.status, "registered")
|
|
55
|
+
self.assertEqual(loaded.openai_password, "openai-pw")
|
|
56
|
+
self.assertEqual(loaded.access_token, "token")
|
|
57
|
+
self.assertEqual(loaded.workspace_id, "ws-1")
|
|
58
|
+
self.assertEqual(loaded.admin_email, "admin@example.com")
|
|
59
|
+
|
|
60
|
+
self.store.delete_worker("slot@example.com")
|
|
61
|
+
self.assertIsNone(self.store.get_worker("slot@example.com"))
|
|
62
|
+
|
|
63
|
+
def test_doctor_rebuilds_broken_worker_index(self) -> None:
|
|
64
|
+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
|
|
65
|
+
worker_dir = self.store.worker_dir / worker.id
|
|
66
|
+
index_path = self.store.worker_dir / "index.json"
|
|
67
|
+
index_path.write_text("{broken", encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
fixes = self.store.doctor()
|
|
70
|
+
|
|
71
|
+
rebuilt = json.loads(index_path.read_text(encoding="utf-8"))
|
|
72
|
+
self.assertIn("slot@example.com", rebuilt)
|
|
73
|
+
self.assertTrue(any("index.json был повреждён" in fix for fix in fixes))
|
|
74
|
+
self.assertTrue(worker_dir.exists())
|
|
75
|
+
|
|
76
|
+
def test_doctor_recreates_missing_meta(self) -> None:
|
|
77
|
+
worker = self.store.add_worker("slot@example.com", "pw", "admin@example.com")
|
|
78
|
+
meta_path = self.store.worker_dir / worker.id / "meta.json"
|
|
79
|
+
meta_path.unlink()
|
|
80
|
+
|
|
81
|
+
fixes = self.store.doctor()
|
|
82
|
+
|
|
83
|
+
rebuilt = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
84
|
+
self.assertEqual(rebuilt["email"], "slot@example.com")
|
|
85
|
+
self.assertEqual(rebuilt["status"], "created")
|
|
86
|
+
self.assertTrue(any("meta.json отсутствовал" in fix for fix in fixes))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from backend.account_store import AdminAccount, WorkerAccount
|
|
6
|
+
from backend.dto import AdminRow, AppStateDTO, WorkerRow
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestDTO(unittest.TestCase):
|
|
10
|
+
def test_admin_row_statuses(self) -> None:
|
|
11
|
+
account = AdminAccount(id="1", email="admin@example.com", password="pw")
|
|
12
|
+
self.assertEqual(AdminRow.from_account(account).status_label, "Не настроен")
|
|
13
|
+
self.assertEqual(AdminRow.from_account(account, has_browser_profile=True).status_label, "Нужен вход")
|
|
14
|
+
|
|
15
|
+
account.access_token = "token"
|
|
16
|
+
self.assertEqual(AdminRow.from_account(account).status_label, "Есть токен")
|
|
17
|
+
self.assertEqual(AdminRow.from_account(account, has_browser_profile=True).status_label, "Готов")
|
|
18
|
+
|
|
19
|
+
def test_worker_row_statuses(self) -> None:
|
|
20
|
+
account = WorkerAccount(id="1", email="slot@example.com", password="pw", admin_email="admin@example.com")
|
|
21
|
+
self.assertEqual(WorkerRow.from_account(account).status_label, "Создан")
|
|
22
|
+
|
|
23
|
+
account.status = "invited"
|
|
24
|
+
self.assertEqual(WorkerRow.from_account(account).status_label, "Инвайт отправлен")
|
|
25
|
+
|
|
26
|
+
account.status = "registered"
|
|
27
|
+
self.assertEqual(WorkerRow.from_account(account).status_label, "Зарегистрирован")
|
|
28
|
+
|
|
29
|
+
account.access_token = "token"
|
|
30
|
+
self.assertEqual(WorkerRow.from_account(account, has_browser_profile=True).status_label, "Готов")
|
|
31
|
+
|
|
32
|
+
def test_app_state_to_dict(self) -> None:
|
|
33
|
+
dto = AppStateDTO(
|
|
34
|
+
admins=[AdminRow.from_account(AdminAccount(id="1", email="admin@example.com", password="pw"))],
|
|
35
|
+
workers=[WorkerRow.from_account(WorkerAccount(id="2", email="slot@example.com", password="pw"))],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
result = dto.to_dict()
|
|
39
|
+
|
|
40
|
+
self.assertIn("admins", result)
|
|
41
|
+
self.assertIn("workers", result)
|
|
42
|
+
self.assertEqual(result["admins"][0]["email"], "admin@example.com")
|
|
43
|
+
self.assertEqual(result["workers"][0]["email"], "slot@example.com")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from backend import DATA_ROOT
|
|
10
|
+
from backend.file_logger import FileLogger
|
|
11
|
+
from backend.jobs import JobManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestJobManager(unittest.TestCase):
|
|
15
|
+
def setUp(self) -> None:
|
|
16
|
+
self.events: list[tuple[str, dict[str, Any]]] = []
|
|
17
|
+
self.logs_root = DATA_ROOT / "downloaded_files" / f"test-logs-{uuid4().hex}"
|
|
18
|
+
self.logger = FileLogger(self.logs_root)
|
|
19
|
+
|
|
20
|
+
def tearDown(self) -> None:
|
|
21
|
+
if self.logs_root.exists():
|
|
22
|
+
for child in sorted(self.logs_root.rglob("*"), reverse=True):
|
|
23
|
+
if child.is_file():
|
|
24
|
+
child.unlink()
|
|
25
|
+
elif child.is_dir():
|
|
26
|
+
child.rmdir()
|
|
27
|
+
|
|
28
|
+
def emit(self, event: str, data: dict[str, Any]) -> None:
|
|
29
|
+
self.events.append((event, data))
|
|
30
|
+
|
|
31
|
+
def test_successful_job_emits_started_progress_done(self) -> None:
|
|
32
|
+
manager = JobManager(self.emit, file_logger=self.logger)
|
|
33
|
+
|
|
34
|
+
def handler(ctx):
|
|
35
|
+
ctx.log("hello")
|
|
36
|
+
ctx.progress(1, 2, "step")
|
|
37
|
+
return {"ok": True}
|
|
38
|
+
|
|
39
|
+
job_id = manager.start("test job", handler)
|
|
40
|
+
manager.wait_all()
|
|
41
|
+
|
|
42
|
+
event_names = [name for name, _ in self.events]
|
|
43
|
+
self.assertEqual(job_id, self.events[0][1]["job_id"])
|
|
44
|
+
self.assertIn("job.started", event_names)
|
|
45
|
+
self.assertIn("job.log", event_names)
|
|
46
|
+
self.assertIn("job.progress", event_names)
|
|
47
|
+
self.assertIn("job.done", event_names)
|
|
48
|
+
|
|
49
|
+
def test_failed_job_emits_error(self) -> None:
|
|
50
|
+
manager = JobManager(self.emit, file_logger=self.logger)
|
|
51
|
+
|
|
52
|
+
def handler(_ctx):
|
|
53
|
+
raise RuntimeError("boom")
|
|
54
|
+
|
|
55
|
+
manager.start("failing job", handler)
|
|
56
|
+
manager.wait_all()
|
|
57
|
+
|
|
58
|
+
last_event, payload = self.events[-1]
|
|
59
|
+
self.assertEqual(last_event, "job.error")
|
|
60
|
+
self.assertEqual(payload["error"], "boom")
|
|
61
|
+
|
|
62
|
+
def test_cannot_start_second_job_while_busy(self) -> None:
|
|
63
|
+
manager = JobManager(self.emit, file_logger=self.logger)
|
|
64
|
+
release = threading.Event()
|
|
65
|
+
|
|
66
|
+
def handler(_ctx):
|
|
67
|
+
release.wait(timeout=2)
|
|
68
|
+
time.sleep(0.05)
|
|
69
|
+
|
|
70
|
+
manager.start("long job", handler)
|
|
71
|
+
try:
|
|
72
|
+
with self.assertRaises(RuntimeError):
|
|
73
|
+
manager.start("second job", handler)
|
|
74
|
+
finally:
|
|
75
|
+
release.set()
|
|
76
|
+
manager.wait_all()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from backend.rpc_protocol import RPCError, make_error_response, make_event, make_success_response, parse_request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestRPCProtocol(unittest.TestCase):
|
|
9
|
+
def test_parse_request_success(self) -> None:
|
|
10
|
+
req = parse_request('{"id":"1","method":"ping","params":{"x":1}}')
|
|
11
|
+
self.assertEqual(req.request_id, "1")
|
|
12
|
+
self.assertEqual(req.method, "ping")
|
|
13
|
+
self.assertEqual(req.params, {"x": 1})
|
|
14
|
+
|
|
15
|
+
def test_parse_request_rejects_invalid_payloads(self) -> None:
|
|
16
|
+
with self.assertRaises(RPCError) as bad_json:
|
|
17
|
+
parse_request("{")
|
|
18
|
+
self.assertEqual(bad_json.exception.code, -32700)
|
|
19
|
+
|
|
20
|
+
with self.assertRaises(RPCError) as bad_id:
|
|
21
|
+
parse_request('{"id":"","method":"ping"}')
|
|
22
|
+
self.assertEqual(bad_id.exception.code, -32600)
|
|
23
|
+
|
|
24
|
+
with self.assertRaises(RPCError) as bad_params:
|
|
25
|
+
parse_request('{"id":"1","method":"ping","params":[]}')
|
|
26
|
+
self.assertEqual(bad_params.exception.code, -32602)
|
|
27
|
+
|
|
28
|
+
def test_response_helpers(self) -> None:
|
|
29
|
+
err = RPCError(-1, "boom", {"detail": "x"})
|
|
30
|
+
|
|
31
|
+
self.assertEqual(make_success_response("1", {"ok": True})["ok"], True)
|
|
32
|
+
self.assertEqual(make_error_response("1", err)["error"]["message"], "boom")
|
|
33
|
+
self.assertEqual(make_event("job.done", {"id": "1"})["type"], "event")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from backend.account_store import AccountStore
|
|
9
|
+
from backend.slot_orchestrator import SlotManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeWorkspaceAPI:
|
|
13
|
+
def __init__(self, members: list[dict[str, Any]], invites: list[dict[str, Any]]) -> None:
|
|
14
|
+
self._members = members
|
|
15
|
+
self._invites = invites
|
|
16
|
+
self.deleted_members: list[str] = []
|
|
17
|
+
self.deleted_invites: list[str] = []
|
|
18
|
+
|
|
19
|
+
def get_members(self) -> list[dict[str, Any]]:
|
|
20
|
+
return list(self._members)
|
|
21
|
+
|
|
22
|
+
def get_pending_invites(self) -> list[dict[str, Any]]:
|
|
23
|
+
return list(self._invites)
|
|
24
|
+
|
|
25
|
+
def delete_member(self, user_id: str) -> dict[str, Any]:
|
|
26
|
+
self.deleted_members.append(user_id)
|
|
27
|
+
return {"ok": True}
|
|
28
|
+
|
|
29
|
+
def delete_invite(self, email: str) -> dict[str, Any]:
|
|
30
|
+
self.deleted_invites.append(email)
|
|
31
|
+
return {"ok": True}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestSlotManagerWorkspaceSync(unittest.TestCase):
|
|
35
|
+
def setUp(self) -> None:
|
|
36
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
37
|
+
self.store = AccountStore(Path(self.temp_dir.name) / "accounts")
|
|
38
|
+
admin = self.store.add_admin("owner@example.com", "secret")
|
|
39
|
+
admin.access_token = "token"
|
|
40
|
+
admin.account_id = "acc-123"
|
|
41
|
+
admin.workspace_id = "acc-123"
|
|
42
|
+
self.store.update_admin(admin)
|
|
43
|
+
self.store.add_worker("slot1@example.com", "pw", "owner@example.com")
|
|
44
|
+
self.store.add_worker("slot2@example.com", "pw", "owner@example.com")
|
|
45
|
+
|
|
46
|
+
def tearDown(self) -> None:
|
|
47
|
+
self.temp_dir.cleanup()
|
|
48
|
+
|
|
49
|
+
def make_manager(self) -> SlotManager:
|
|
50
|
+
return SlotManager(store=self.store, admin_email="owner@example.com", log=lambda _msg: None, headless=True)
|
|
51
|
+
|
|
52
|
+
def test_build_workspace_sync_plan_filters_local_and_protected_entries(self) -> None:
|
|
53
|
+
manager = self.make_manager()
|
|
54
|
+
members = [
|
|
55
|
+
{"id": "owner-id", "email": "owner@example.com", "role": "account-owner"},
|
|
56
|
+
{"id": "slot-id", "email": "slot1@example.com", "role": "standard-user"},
|
|
57
|
+
{"id": "extra-id", "email": "extra@example.com", "role": "standard-user"},
|
|
58
|
+
{"id": "admin-id", "email": "admin2@example.com", "role": "workspace-admin"},
|
|
59
|
+
]
|
|
60
|
+
invites = [
|
|
61
|
+
{"email": "slot2@example.com"},
|
|
62
|
+
{"email": "invite@example.com"},
|
|
63
|
+
{"email_address": "owner@example.com"},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
plan = manager._build_workspace_sync_plan(members, invites)
|
|
67
|
+
|
|
68
|
+
self.assertEqual([item["email"] for item in plan["extra_members"]], ["extra@example.com"])
|
|
69
|
+
self.assertEqual([item["email"] for item in plan["extra_invites"]], ["invite@example.com"])
|
|
70
|
+
self.assertEqual(
|
|
71
|
+
sorted((item["email"], item["reason"]) for item in plan["skipped"]),
|
|
72
|
+
[
|
|
73
|
+
("admin2@example.com", "role:workspace-admin"),
|
|
74
|
+
("owner@example.com", "self"),
|
|
75
|
+
("owner@example.com", "self"),
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def test_sync_workspace_dry_run_does_not_delete_anything(self) -> None:
|
|
80
|
+
manager = self.make_manager()
|
|
81
|
+
api = FakeWorkspaceAPI(
|
|
82
|
+
members=[{"id": "extra-id", "email": "extra@example.com", "role": "standard-user"}],
|
|
83
|
+
invites=[{"email": "invite@example.com"}],
|
|
84
|
+
)
|
|
85
|
+
manager._ensure_admin_page = lambda: object() # type: ignore[method-assign]
|
|
86
|
+
manager._get_api = lambda _page: api # type: ignore[method-assign]
|
|
87
|
+
|
|
88
|
+
result = manager.sync_workspace(dry_run=True)
|
|
89
|
+
|
|
90
|
+
self.assertTrue(result["dry_run"])
|
|
91
|
+
self.assertEqual([item["email"] for item in result["extra_members"]], ["extra@example.com"])
|
|
92
|
+
self.assertEqual([item["email"] for item in result["extra_invites"]], ["invite@example.com"])
|
|
93
|
+
self.assertEqual(api.deleted_members, [])
|
|
94
|
+
self.assertEqual(api.deleted_invites, [])
|
|
95
|
+
|
|
96
|
+
def test_sync_workspace_apply_deletes_extra_members_and_invites(self) -> None:
|
|
97
|
+
manager = self.make_manager()
|
|
98
|
+
api = FakeWorkspaceAPI(
|
|
99
|
+
members=[{"id": "extra-id", "email": "extra@example.com", "role": "standard-user"}],
|
|
100
|
+
invites=[{"email": "invite@example.com"}],
|
|
101
|
+
)
|
|
102
|
+
manager._ensure_admin_page = lambda: object() # type: ignore[method-assign]
|
|
103
|
+
manager._get_api = lambda _page: api # type: ignore[method-assign]
|
|
104
|
+
|
|
105
|
+
result = manager.sync_workspace(dry_run=False)
|
|
106
|
+
|
|
107
|
+
self.assertFalse(result["dry_run"])
|
|
108
|
+
self.assertEqual(result["removed_members"], ["extra@example.com"])
|
|
109
|
+
self.assertEqual(result["removed_invites"], ["invite@example.com"])
|
|
110
|
+
self.assertEqual(api.deleted_members, ["extra-id"])
|
|
111
|
+
self.assertEqual(api.deleted_invites, ["invite@example.com"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
unittest.main()
|
package/ui/package.json
CHANGED
|
@@ -14,6 +14,7 @@ export function getMenuOptions(menuName: MenuName, state: AppState): MenuOption[
|
|
|
14
14
|
return [
|
|
15
15
|
{ id: "adm_add", label: "Добавить админа", hint: "Новый админ", description: "Добавить админа и выбрать способ входа." },
|
|
16
16
|
{ id: "adm_relogin", label: "Перелогинить", hint: "Обновить доступ", description: "Перелогинить выбранного админа." },
|
|
17
|
+
{ id: "adm_sync_ws", label: "Синхронизировать WS", hint: "Удалить лишних", description: "Сверить workspace с локальными слотами и удалить лишние записи." },
|
|
17
18
|
{ id: "adm_open", label: "Открыть браузер", hint: "Открыть профиль", description: "Открыть браузерный профиль админа." },
|
|
18
19
|
{ id: "adm_delete", label: "Удалить", hint: "Удалить данные", description: "Удалить админа и его локальные файлы.", destructive: true },
|
|
19
20
|
]
|
package/ui/src/menus/types.ts
CHANGED
|
@@ -59,6 +59,12 @@ export interface MenuContext {
|
|
|
59
59
|
admin_email?: string
|
|
60
60
|
target?: string
|
|
61
61
|
confirm_action?: string
|
|
62
|
+
sync_preview?: {
|
|
63
|
+
admin_email: string
|
|
64
|
+
extra_members: string[]
|
|
65
|
+
extra_invites: string[]
|
|
66
|
+
skipped: string[]
|
|
67
|
+
}
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export interface DashboardData {
|
|
@@ -13,6 +13,12 @@ const EMPTY_STATE: AppState = {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
type RpcJobResult = { job_id: string }
|
|
16
|
+
type WorkspaceSyncPreview = {
|
|
17
|
+
admin_email: string
|
|
18
|
+
extra_members: Array<{ email?: string }>
|
|
19
|
+
extra_invites: Array<{ email?: string }>
|
|
20
|
+
skipped: Array<{ email?: string; reason?: string; type?: string }>
|
|
21
|
+
}
|
|
16
22
|
|
|
17
23
|
type PromptOption = {
|
|
18
24
|
label: string
|
|
@@ -147,6 +153,7 @@ function getMenuOptionDescription(option: MenuOption | undefined): string {
|
|
|
147
153
|
|
|
148
154
|
if (optionId === "adm_add") return "Добавить нового админа."
|
|
149
155
|
if (optionId === "adm_relogin") return "Перелогинить админа."
|
|
156
|
+
if (optionId === "adm_sync_ws") return "Сверить workspace с локальными слотами и удалить лишнее."
|
|
150
157
|
if (optionId === "adm_open") return "Открыть профиль админа."
|
|
151
158
|
if (optionId === "adm_delete") return "Удалить админа."
|
|
152
159
|
|
|
@@ -1090,6 +1097,30 @@ export class MainScreen {
|
|
|
1090
1097
|
}
|
|
1091
1098
|
|
|
1092
1099
|
if (this.menuName === "confirm") {
|
|
1100
|
+
const preview = this.menuCtx.sync_preview
|
|
1101
|
+
if (preview) {
|
|
1102
|
+
const lines = [
|
|
1103
|
+
`Админ: ${preview.admin_email}`,
|
|
1104
|
+
`Лишних участников: ${preview.extra_members.length}`,
|
|
1105
|
+
`Лишних инвайтов: ${preview.extra_invites.length}`,
|
|
1106
|
+
]
|
|
1107
|
+
if (preview.extra_members.length > 0) {
|
|
1108
|
+
lines.push(`Members: ${preview.extra_members.slice(0, 4).join(", ")}`)
|
|
1109
|
+
if (preview.extra_members.length > 4) {
|
|
1110
|
+
lines.push(`Ещё участников: ${preview.extra_members.length - 4}`)
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (preview.extra_invites.length > 0) {
|
|
1114
|
+
lines.push(`Invites: ${preview.extra_invites.slice(0, 4).join(", ")}`)
|
|
1115
|
+
if (preview.extra_invites.length > 4) {
|
|
1116
|
+
lines.push(`Ещё инвайтов: ${preview.extra_invites.length - 4}`)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (preview.skipped.length > 0) {
|
|
1120
|
+
lines.push(`Пропущено защищённых: ${preview.skipped.length}`)
|
|
1121
|
+
}
|
|
1122
|
+
return lines
|
|
1123
|
+
}
|
|
1093
1124
|
return [
|
|
1094
1125
|
`Действие: ${this.menuCtx.confirm_action ?? "подтверждение"}`,
|
|
1095
1126
|
`Объект: ${this.menuCtx.target ?? "не выбран"}`,
|
|
@@ -1237,6 +1268,10 @@ export class MainScreen {
|
|
|
1237
1268
|
this.goToPicker("pick_admin", "admins", "relogin_admin", "Выберите админа")
|
|
1238
1269
|
return
|
|
1239
1270
|
}
|
|
1271
|
+
if (optionId === "adm_sync_ws") {
|
|
1272
|
+
this.goToPicker("pick_admin", "admins", "sync_workspace", "Выберите админа для синхронизации WS")
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1240
1275
|
if (optionId === "adm_open") {
|
|
1241
1276
|
this.goToPicker("pick_admin", "admins", "open_admin", "Открыть браузер админа")
|
|
1242
1277
|
return
|
|
@@ -1307,6 +1342,61 @@ export class MainScreen {
|
|
|
1307
1342
|
await this.startJob("job.open_admin_browser", { email })
|
|
1308
1343
|
return
|
|
1309
1344
|
}
|
|
1345
|
+
if (action === "sync_workspace") {
|
|
1346
|
+
let preview: WorkspaceSyncPreview
|
|
1347
|
+
try {
|
|
1348
|
+
preview = await this.rpc.request<WorkspaceSyncPreview>("workspace.sync_preview", { admin_email: email })
|
|
1349
|
+
this.backendOnline = true
|
|
1350
|
+
} catch (error) {
|
|
1351
|
+
this.pushLog(`Ошибка preview WS: ${String(error)}`)
|
|
1352
|
+
this.render()
|
|
1353
|
+
return
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const extraMembers = preview.extra_members
|
|
1357
|
+
.map((item) => item.email?.trim())
|
|
1358
|
+
.filter((item): item is string => Boolean(item))
|
|
1359
|
+
const extraInvites = preview.extra_invites
|
|
1360
|
+
.map((item) => item.email?.trim())
|
|
1361
|
+
.filter((item): item is string => Boolean(item))
|
|
1362
|
+
const skipped = preview.skipped
|
|
1363
|
+
.map((item) => {
|
|
1364
|
+
const emailPart = item.email?.trim() || "?"
|
|
1365
|
+
const reasonPart = item.reason ? ` (${item.reason})` : ""
|
|
1366
|
+
return `${emailPart}${reasonPart}`
|
|
1367
|
+
})
|
|
1368
|
+
.filter((item): item is string => Boolean(item))
|
|
1369
|
+
|
|
1370
|
+
if (extraMembers.length === 0 && extraInvites.length === 0) {
|
|
1371
|
+
this.pushLog(`WS уже синхронизирован: ${email}`)
|
|
1372
|
+
if (skipped.length > 0) {
|
|
1373
|
+
this.pushLog(`Пропущено защищённых записей: ${skipped.length}`)
|
|
1374
|
+
}
|
|
1375
|
+
this.menuName = parent
|
|
1376
|
+
this.menuCtx = {}
|
|
1377
|
+
await this.refreshState()
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
this.menuName = "confirm"
|
|
1382
|
+
this.menuCtx = {
|
|
1383
|
+
parent,
|
|
1384
|
+
action: "sync_workspace",
|
|
1385
|
+
admin_email: email,
|
|
1386
|
+
confirm_action: "Синхронизация WS",
|
|
1387
|
+
title: `Синхронизация WS: ${email}`,
|
|
1388
|
+
target: `${email} • members ${extraMembers.length} • invites ${extraInvites.length}`,
|
|
1389
|
+
sync_preview: {
|
|
1390
|
+
admin_email: email,
|
|
1391
|
+
extra_members: extraMembers,
|
|
1392
|
+
extra_invites: extraInvites,
|
|
1393
|
+
skipped,
|
|
1394
|
+
},
|
|
1395
|
+
}
|
|
1396
|
+
this.selectedIndex = 1
|
|
1397
|
+
this.render()
|
|
1398
|
+
return
|
|
1399
|
+
}
|
|
1310
1400
|
if (action === "delete_admin") {
|
|
1311
1401
|
this.goToConfirm(parent, "delete_admin", "Удаление админа", email)
|
|
1312
1402
|
return
|
|
@@ -1372,6 +1462,14 @@ export class MainScreen {
|
|
|
1372
1462
|
await this.rpc.request("worker.delete", { email: target })
|
|
1373
1463
|
this.pushLog(`Удалён слот: ${target}`)
|
|
1374
1464
|
}
|
|
1465
|
+
if (action === "sync_workspace") {
|
|
1466
|
+
const adminEmail = this.menuCtx.admin_email
|
|
1467
|
+
if (!adminEmail) throw new Error("admin_email не задан")
|
|
1468
|
+
await this.startJob("job.sync_workspace", { admin_email: adminEmail })
|
|
1469
|
+
this.menuName = parent
|
|
1470
|
+
this.menuCtx = {}
|
|
1471
|
+
return
|
|
1472
|
+
}
|
|
1375
1473
|
} catch (error) {
|
|
1376
1474
|
this.pushLog(`Ошибка: ${String(error)}`)
|
|
1377
1475
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
|
|
4
|
+
import { getDashboard, getMenuOptions, getTable } from "../src/menus/mainMenus"
|
|
5
|
+
import type { AppState } from "../src/menus/types"
|
|
6
|
+
|
|
7
|
+
const state: AppState = {
|
|
8
|
+
admins: [
|
|
9
|
+
{
|
|
10
|
+
email: "admin@example.com",
|
|
11
|
+
has_access_token: true,
|
|
12
|
+
has_browser_profile: true,
|
|
13
|
+
workspace_id: "ws-1",
|
|
14
|
+
workspace_count: 2,
|
|
15
|
+
created_at: "2026-03-07T00:00:00Z",
|
|
16
|
+
last_login: "2026-03-07T01:00:00Z",
|
|
17
|
+
status_label: "Готов",
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
workers: [
|
|
21
|
+
{
|
|
22
|
+
email: "slot1@example.com",
|
|
23
|
+
status: "registered",
|
|
24
|
+
has_access_token: true,
|
|
25
|
+
has_browser_profile: true,
|
|
26
|
+
workspace_id: "ws-1",
|
|
27
|
+
admin_email: "admin@example.com",
|
|
28
|
+
has_openai_password: true,
|
|
29
|
+
created_at: "2026-03-07T00:00:00Z",
|
|
30
|
+
status_label: "Готов",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
email: "slot2@example.com",
|
|
34
|
+
status: "invited",
|
|
35
|
+
has_access_token: false,
|
|
36
|
+
has_browser_profile: false,
|
|
37
|
+
workspace_id: "ws-1",
|
|
38
|
+
admin_email: "other@example.com",
|
|
39
|
+
has_openai_password: false,
|
|
40
|
+
created_at: "2026-03-07T00:00:00Z",
|
|
41
|
+
status_label: "Инвайт отправлен",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("admin menu exposes workspace sync action", () => {
|
|
47
|
+
const options = getMenuOptions("admins", state)
|
|
48
|
+
const syncOption = options.find((option) => option.id === "adm_sync_ws")
|
|
49
|
+
|
|
50
|
+
assert.ok(syncOption)
|
|
51
|
+
assert.equal(syncOption?.label, "Синхронизировать WS")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("dashboard counts ready and invited entities", () => {
|
|
55
|
+
const dashboard = getDashboard(state)
|
|
56
|
+
|
|
57
|
+
assert.equal(dashboard.admins_total, 1)
|
|
58
|
+
assert.equal(dashboard.admins_ready, 1)
|
|
59
|
+
assert.equal(dashboard.workers_total, 2)
|
|
60
|
+
assert.equal(dashboard.workers_ready, 1)
|
|
61
|
+
assert.equal(dashboard.workers_invited, 1)
|
|
62
|
+
assert.equal(dashboard.workers_with_password, 1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("worker table respects admin filter", () => {
|
|
66
|
+
const table = getTable("slots", state, { admin_email: "admin@example.com" })
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(table.headers, ["Email", "Состояние", "Доступ", "Админ", "Пароль"])
|
|
69
|
+
assert.equal(table.rows.length, 1)
|
|
70
|
+
assert.equal(table.rows[0][0], "slot1@example.com")
|
|
71
|
+
})
|