izteamslots 1.5.5 → 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 +14 -0
- package/CONTRIBUTING.md +20 -8
- package/backend/rpc_server.py +13 -0
- package/backend/slot_orchestrator.py +101 -19
- package/backend/ui_facade.py +13 -5
- 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 +104 -1
- package/ui/tests/mainMenus.test.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
# [1.6.0](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.5...v1.6.0) (2026-03-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **auth:** disable automatic admin login ([930313f](https://github.com/izzzzzi/izTeamSlots/commit/930313f6ef2f06674cc9da6e8d45061dc8c89a93))
|
|
14
|
+
|
|
1
15
|
## [1.5.5](https://github.com/izzzzzi/izTeamSlots/compare/v1.5.4...v1.5.5) (2026-03-06)
|
|
2
16
|
|
|
3
17
|
|
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(
|
|
@@ -173,25 +173,7 @@ class SlotManager:
|
|
|
173
173
|
self._finalize_admin_login(page, session, manual_web_flow=True)
|
|
174
174
|
|
|
175
175
|
def login_admin(self) -> None:
|
|
176
|
-
"
|
|
177
|
-
if not self._admin:
|
|
178
|
-
raise RuntimeError(f"Админ {self.admin_email} не найден в AccountStore")
|
|
179
|
-
|
|
180
|
-
mailbox = Mailbox(email=self._admin.email, password=self._admin.password)
|
|
181
|
-
profile_dir = self.store.get_admin_profile_dir(self._admin)
|
|
182
|
-
mail = self._get_admin_mail()
|
|
183
|
-
|
|
184
|
-
self._log("Логин админа (OAuth PKCE)...")
|
|
185
|
-
page, session = oauth_login(
|
|
186
|
-
email=self._admin.email,
|
|
187
|
-
password=self._admin.password,
|
|
188
|
-
mail_client=mail,
|
|
189
|
-
mailbox=mailbox,
|
|
190
|
-
profile_dir=profile_dir,
|
|
191
|
-
log=self._log,
|
|
192
|
-
headless=self._headless,
|
|
193
|
-
)
|
|
194
|
-
self._finalize_admin_login(page, session)
|
|
176
|
+
raise RuntimeError("Авто-вход админа временно отключён. Используйте ручной вход через браузер.")
|
|
195
177
|
|
|
196
178
|
def login_admin_manual(self) -> None:
|
|
197
179
|
"""Ручной логин админа в браузере с последующим автоматическим сохранением токенов."""
|
|
@@ -328,6 +310,106 @@ class SlotManager:
|
|
|
328
310
|
api = self._get_api(page)
|
|
329
311
|
return api.get_members()
|
|
330
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
|
+
|
|
331
413
|
def register_slot(self, worker: WorkerAccount, invite_url: str) -> None:
|
|
332
414
|
"""Зарегистрировать приглашённый аккаунт через инвайт-ссылку."""
|
|
333
415
|
mailbox = self._mailboxes.get(worker.email)
|
package/backend/ui_facade.py
CHANGED
|
@@ -201,10 +201,7 @@ class UIFacade:
|
|
|
201
201
|
self.manager = manager
|
|
202
202
|
|
|
203
203
|
def login_admin(self, email: str, log: LogFunc) -> None:
|
|
204
|
-
|
|
205
|
-
self._replace_manager(manager)
|
|
206
|
-
manager.login_admin()
|
|
207
|
-
self.sync_codex_files()
|
|
204
|
+
raise RuntimeError("Авто-вход админа временно отключён. Используйте ручной вход через браузер.")
|
|
208
205
|
|
|
209
206
|
def login_admin_manual(self, email: str, log: LogFunc) -> None:
|
|
210
207
|
manager = SlotManager(store=self.store, admin_email=email, log=log)
|
|
@@ -212,6 +209,18 @@ class UIFacade:
|
|
|
212
209
|
manager.login_admin_manual()
|
|
213
210
|
self.sync_codex_files()
|
|
214
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
|
+
|
|
215
224
|
def run_slots_pipeline(
|
|
216
225
|
self,
|
|
217
226
|
admin_email: str,
|
|
@@ -307,4 +316,3 @@ class UIFacade:
|
|
|
307
316
|
log(f"Готово: {ok}/{total}")
|
|
308
317
|
self.sync_codex_files()
|
|
309
318
|
return {"ok": ok, "total": total}
|
|
310
|
-
|
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 ?? "не выбран"}`,
|
|
@@ -1166,9 +1197,14 @@ export class MainScreen {
|
|
|
1166
1197
|
|
|
1167
1198
|
private async promptLoginMode(): Promise<"auto" | "manual" | null> {
|
|
1168
1199
|
const value = await this.promptSelect("Режим входа", [
|
|
1169
|
-
{ value: "auto", label: "Авто", hint: "
|
|
1200
|
+
{ value: "auto", label: "Авто", hint: "Временно недоступно." },
|
|
1170
1201
|
{ value: "manual", label: "Вручную", hint: "Логин вручную в браузере." },
|
|
1171
1202
|
])
|
|
1203
|
+
if (value === "auto") {
|
|
1204
|
+
this.pushLog("Авто-вход админа временно отключён. Используйте ручной режим.")
|
|
1205
|
+
this.render()
|
|
1206
|
+
return null
|
|
1207
|
+
}
|
|
1172
1208
|
if (value === "auto" || value === "manual") return value
|
|
1173
1209
|
return null
|
|
1174
1210
|
}
|
|
@@ -1232,6 +1268,10 @@ export class MainScreen {
|
|
|
1232
1268
|
this.goToPicker("pick_admin", "admins", "relogin_admin", "Выберите админа")
|
|
1233
1269
|
return
|
|
1234
1270
|
}
|
|
1271
|
+
if (optionId === "adm_sync_ws") {
|
|
1272
|
+
this.goToPicker("pick_admin", "admins", "sync_workspace", "Выберите админа для синхронизации WS")
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1235
1275
|
if (optionId === "adm_open") {
|
|
1236
1276
|
this.goToPicker("pick_admin", "admins", "open_admin", "Открыть браузер админа")
|
|
1237
1277
|
return
|
|
@@ -1302,6 +1342,61 @@ export class MainScreen {
|
|
|
1302
1342
|
await this.startJob("job.open_admin_browser", { email })
|
|
1303
1343
|
return
|
|
1304
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
|
+
}
|
|
1305
1400
|
if (action === "delete_admin") {
|
|
1306
1401
|
this.goToConfirm(parent, "delete_admin", "Удаление админа", email)
|
|
1307
1402
|
return
|
|
@@ -1367,6 +1462,14 @@ export class MainScreen {
|
|
|
1367
1462
|
await this.rpc.request("worker.delete", { email: target })
|
|
1368
1463
|
this.pushLog(`Удалён слот: ${target}`)
|
|
1369
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
|
+
}
|
|
1370
1473
|
} catch (error) {
|
|
1371
1474
|
this.pushLog(`Ошибка: ${String(error)}`)
|
|
1372
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
|
+
})
|