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 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 — ruff
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 — tsc
54
- npm run typecheck:ui
54
+ # TypeScript — typecheck + unit tests
55
+ npm --prefix ui run typecheck
56
+ npm --prefix ui run test
55
57
  ```
56
58
 
57
- CI автоматически запускает оба линтера на каждый push и PR.
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. Убедитесь что `ruff check` и `tsc --noEmit` проходят.
104
- 4. Обновите документацию если изменение затрагивает пользовательское поведение.
114
+ 3. Добавьте или обновите тесты, если меняется логика приложения.
115
+ 4. Убедитесь что lint, unit tests и typecheck проходят локально.
116
+ 5. Обновите документацию если изменение затрагивает пользовательское поведение.
105
117
 
106
118
  ### Новый почтовый провайдер
107
119
 
@@ -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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "izteamslots",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "ChatGPT Team slot management — automated invite, register, OAuth login & codex token pipeline",
5
5
  "bin": {
6
6
  "izteamslots": "bin/izteamslots.mjs"
@@ -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
@@ -5,7 +5,8 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "bun run src/main.ts",
8
- "typecheck": "tsc --noEmit"
8
+ "typecheck": "tsc --noEmit",
9
+ "test": "bun test tests"
9
10
  },
10
11
  "dependencies": {
11
12
  "@opentui/core": "^0.1.86"
@@ -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
  ]
@@ -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
+ })