fix(ci): stop hanging hermetic tests and unblock clean/headless runners

Preflight and updater tests no longer probe 8081/8082 or GitHub.
Hermetic runs fail-fast on non-loopback sockets. GUI helpers are
importable without customtkinter. CI installs the web extra, and
pytest-timeout plus a wall-clock wrapper bound the suite.
This commit is contained in:
ochenstarik-ui 2026-08-27 12:03:37 +00:00
parent 59d57a41ef
commit b4ae08ef53
17 changed files with 581 additions and 94 deletions

View file

@ -10,6 +10,7 @@ jobs:
test:
name: Clean Windows Runner Test
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout repository
@ -24,7 +25,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install -e ".[dev,web]"
- name: Code Quality (ruff)
run: |
@ -41,6 +42,7 @@ jobs:
headless:
name: Headless Run (no GUI dependencies)
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
@ -54,7 +56,7 @@ jobs:
- name: Install dependencies without GUI extras
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install -e ".[dev,web]"
pip uninstall -y customtkinter
# A test module importing customtkinter at module scope aborts collection

View file

@ -22,7 +22,7 @@ jobs:
- name: Install dependencies & dev tools
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install -e ".[dev,web]"
- name: Run Release Gate Check
run: |

View file

@ -46,6 +46,7 @@ dependencies = [
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-timeout>=2.3.0",
"anyio>=4.0.0",
"ruff>=0.3.0",
]
@ -65,6 +66,8 @@ testpaths = ["tests"]
pythonpath = ["src"]
python_files = ["test_*.py"]
addopts = "-m 'not live and not network and not installer'"
timeout = 30
timeout_method = "thread"
markers = [
"unit: Unit tests that run isolated in-memory",
"integration: Component integration tests with isolated filesystem",

View file

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Hard wall-clock wrapper for the hermetic pytest suite.
A hanging suite is terminated. The last collected/running node is written
to artifacts/test-diagnostics/last-running-test.txt when possible.
"""
from __future__ import annotations
import argparse
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_LIMIT = 480 # 8 minutes: enough for ~500 hermetic tests, not 15 hours
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="Wall-clock seconds")
parser.add_argument("pytest_args", nargs=argparse.REMAINDER)
args = parser.parse_args()
pytest_args = list(args.pytest_args)
if pytest_args and pytest_args[0] == "--":
pytest_args = pytest_args[1:]
out_dir = ROOT / "artifacts" / "test-diagnostics"
out_dir.mkdir(parents=True, exist_ok=True)
last_path = out_dir / "last-running-test.txt"
cmd = [
args.python,
"-X",
"faulthandler",
"-m",
"pytest",
"-vv",
"--tb=short",
*pytest_args,
]
env = os.environ.copy()
env["PYTHONFAULTHANDLER"] = "1"
start = time.monotonic()
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
start_new_session=True,
)
try:
return_code = proc.wait(timeout=args.limit)
last_path.write_text(
f"completed rc={return_code} duration={time.monotonic() - start:.1f}s\n",
encoding="utf-8",
)
return return_code
except subprocess.TimeoutExpired:
last_path.write_text(
f"TIMEOUT after {args.limit}s pid={proc.pid}\ncmd={' '.join(cmd)}\n",
encoding="utf-8",
)
try:
os.killpg(proc.pid, signal.SIGABRT)
time.sleep(0.5)
except (ProcessLookupError, PermissionError, OSError):
pass
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
print(
f"HERMETIC SUITE WALL CLOCK EXCEEDED ({args.limit}s). Killed pid={proc.pid}. "
f"See {last_path}",
file=sys.stderr,
)
return 124
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Per-file and per-node pytest hang diagnostic runner.
Each file (or node) is a separate subprocess with a hard timeout.
One hang never blocks the rest of the suite.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUT = ROOT / "artifacts" / "test-diagnostics"
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def list_test_files() -> list[Path]:
tests_dir = ROOT / "tests"
return sorted(p for p in tests_dir.glob("test_*.py") if p.is_file())
def collect_node_ids(python: str, test_file: Path, collect_timeout: int) -> list[str]:
cmd = [
python,
"-m",
"pytest",
str(test_file),
"--collect-only",
"-q",
"--no-header",
]
proc = subprocess.run(
cmd,
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=collect_timeout,
env=os.environ.copy(),
)
nodes: list[str] = []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(str(test_file).replace("\\", "/")) or line.startswith("tests/"):
if "::" in line and not line.startswith("="):
nodes.append(line.split()[0])
elif "::" in line and not line.startswith("=") and "error" not in line.lower():
if line.startswith("test_") or "/test_" in line or line.startswith("tests"):
nodes.append(line.split()[0])
# Fallback: pytest -q collect prints node ids as first token.
if not nodes:
for line in (proc.stdout or "").splitlines():
stripped = line.strip()
if "::" in stripped and not stripped.startswith("="):
nodes.append(stripped.split()[0])
return nodes
def _kill_tree(proc: subprocess.Popen) -> None:
if proc.poll() is not None:
return
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def run_guarded(
python: str,
target: str,
timeout_s: int,
log_dir: Path,
extra_args: list[str] | None = None,
) -> dict:
safe_name = target.replace("/", "_").replace("::", "__").replace("[", "_").replace("]", "_")
stdout_path = log_dir / f"{safe_name}.stdout.txt"
stderr_path = log_dir / f"{safe_name}.stderr.txt"
dump_path = log_dir / f"{safe_name}.faulthandler.txt"
cmd = [
python,
"-X",
"faulthandler",
"-m",
"pytest",
target,
"-vv",
"--tb=short",
"-p",
"no:cacheprovider",
]
if extra_args:
cmd.extend(extra_args)
env = os.environ.copy()
env["PYTHONFAULTHANDLER"] = "1"
start = time.monotonic()
start_iso = utc_now()
status = "UNKNOWN"
return_code: int | None = None
timed_out = False
dump = ""
stdout_text = ""
stderr_text = ""
try:
with open(stdout_path, "w", encoding="utf-8") as out_f, open(
stderr_path, "w", encoding="utf-8"
) as err_f:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
stdout=out_f,
stderr=err_f,
text=True,
start_new_session=True,
env=env,
)
try:
return_code = proc.wait(timeout=timeout_s)
except subprocess.TimeoutExpired:
timed_out = True
try:
os.killpg(proc.pid, signal.SIGABRT)
time.sleep(0.4)
except (ProcessLookupError, PermissionError, OSError):
pass
_kill_tree(proc)
return_code = -9
except Exception:
dump = traceback.format_exc()
status = "ERROR"
return_code = -1
duration = round(time.monotonic() - start, 3)
try:
stdout_text = stdout_path.read_text(encoding="utf-8", errors="replace")
except OSError:
stdout_text = ""
try:
stderr_text = stderr_path.read_text(encoding="utf-8", errors="replace")
except OSError:
stderr_text = ""
if timed_out:
status = "TIMEOUT"
dump_parts = [dump, "=== STDERR TAIL ===\n" + stderr_text[-8000:], "=== STDOUT TAIL ===\n" + stdout_text[-8000:]]
dump = "\n".join(p for p in dump_parts if p)
dump_path.write_text(dump, encoding="utf-8")
elif return_code == 0:
status = "PASS"
else:
status = "FAIL"
dump_path.write_text(
(stderr_text[-8000:] + "\n" + stdout_text[-8000:]),
encoding="utf-8",
)
return {
"target": target,
"start_time": start_iso,
"duration": duration,
"return_code": return_code,
"status": status,
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"dump_path": str(dump_path) if dump_path.exists() else None,
"timed_out": timed_out,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--file-timeout", type=int, default=90)
parser.add_argument("--node-timeout", type=int, default=30)
parser.add_argument("--collect-timeout", type=int, default=30)
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
parser.add_argument("--mode", choices=["files", "nodes"], default="files")
parser.add_argument("--file", action="append", default=[])
args = parser.parse_args()
out_dir = args.out
logs = out_dir / ("file-logs" if args.mode == "files" else "node-logs")
logs.mkdir(parents=True, exist_ok=True)
if args.file:
files = [Path(f) if Path(f).is_absolute() else ROOT / f for f in args.file]
else:
files = list_test_files()
results: list[dict] = []
if args.mode == "files":
for path in files:
rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
print(f"[RUN FILE] {rel}", flush=True)
rec = run_guarded(args.python, rel, args.file_timeout, logs)
rec["file"] = rel
results.append(rec)
print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
payload = {
"generated_at": utc_now(),
"mode": "files",
"file_timeout": args.file_timeout,
"results": results,
"summary": {
"total": len(results),
"pass": sum(1 for r in results if r["status"] == "PASS"),
"fail": sum(1 for r in results if r["status"] == "FAIL"),
"timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
},
}
out_path = out_dir / "file-results.json"
else:
for path in files:
rel = str(path.relative_to(ROOT)) if path.is_absolute() else str(path)
print(f"[COLLECT] {rel}", flush=True)
try:
nodes = collect_node_ids(args.python, Path(rel), args.collect_timeout)
except subprocess.TimeoutExpired:
results.append(
{
"file": rel,
"target": rel,
"status": "COLLECT_TIMEOUT",
"start_time": utc_now(),
"duration": args.collect_timeout,
"return_code": -9,
"timed_out": True,
}
)
continue
if not nodes:
print(f" no nodes collected for {rel}", flush=True)
continue
for node in nodes:
print(f"[RUN NODE] {node}", flush=True)
rec = run_guarded(args.python, node, args.node_timeout, logs)
rec["file"] = rel
rec["node_id"] = node
results.append(rec)
print(f" -> {rec['status']} {rec['duration']}s rc={rec['return_code']}", flush=True)
payload = {
"generated_at": utc_now(),
"mode": "nodes",
"node_timeout": args.node_timeout,
"results": results,
"summary": {
"total": len(results),
"pass": sum(1 for r in results if r["status"] == "PASS"),
"fail": sum(1 for r in results if r["status"] == "FAIL"),
"timeout": sum(1 for r in results if r["status"] == "TIMEOUT"),
},
}
out_path = out_dir / "node-results.json"
out_dir.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
print(json.dumps(payload["summary"], indent=2), flush=True)
print(f"wrote {out_path}", flush=True)
return 0 if payload["summary"]["timeout"] == 0 else 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -586,3 +586,19 @@ class AutoAssigner:
level="info",
)
return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}"
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
"""Keep existing chain rank or route a newly introduced profile slot.
Lives outside the GUI wizard so hermetic tests can import it without customtkinter.
"""
config = load_router_config()
assigned_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"",
)
if assigned_role:
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")

View file

@ -22,25 +22,12 @@ import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.unified_health import EventLogService
def ensure_profile_in_routing(profile_id: str) -> tuple[bool, str]:
"""Keep existing chain rank or route a newly introduced profile slot."""
config = load_router_config()
assigned_role = next(
(role_id for role_id, policy in config.roles.items() if profile_id in policy.preferred_chain),
"",
)
if assigned_role:
return True, f"Профиль уже входит в цепочку '{assigned_role}'"
_display_name, role_code, tier = AutoAssigner.get_display_name_and_role(profile_id)
return AutoAssigner.assign_profile_to_role(profile_id, role_code, is_primary=tier == "primary")
class AddAccountWizard(HubModal):
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""

View file

@ -4,10 +4,12 @@ Enforces:
1. Zero modification to real user credentials or router_profiles.yaml.
2. Complete filesystem sandboxing in temporary directory via HERMES_HOME.
3. Offline execution for default test runs (network / live require explicit -m markers).
4. No accidental connects to local llama.cpp (8081/8082) or non-loopback hosts.
"""
from __future__ import annotations
import os
import socket
import sys
from pathlib import Path
import pytest
@ -16,6 +18,9 @@ REPO_SRC = Path(__file__).resolve().parent.parent / "src"
if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC):
sys.path.insert(0, str(REPO_SRC))
BLOCKED_INFERENCE_PORTS = {8081, 8082}
LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "::"}
@pytest.fixture(autouse=True)
def isolate_hermes_environment(tmp_path, monkeypatch):
@ -35,6 +40,62 @@ def isolate_hermes_environment(tmp_path, monkeypatch):
yield temp_hermes
def _socket_host_port(address) -> tuple[str | None, int | None]:
if isinstance(address, tuple) and len(address) >= 2:
host = address[0]
port = address[1]
if isinstance(host, bytes):
host = host.decode("utf-8", errors="replace")
try:
return str(host), int(port)
except (TypeError, ValueError):
return str(host), None
return None, None
def _reject_hermetic_connect(address) -> None:
host, port = _socket_host_port(address)
if port in BLOCKED_INFERENCE_PORTS:
raise RuntimeError(
f"hermetic tests must not contact local inference at {host}:{port}; "
"mock check_local_servers / urllib.request.urlopen"
)
if host and host not in LOOPBACK_HOSTS and not host.startswith("127."):
raise RuntimeError(
f"hermetic tests must not open network connections to {host}:{port}; "
"mark the test live/network or mock the call"
)
@pytest.fixture(autouse=True)
def block_external_network_in_hermetic_tests(request, monkeypatch):
"""Fail fast instead of hanging on llama.cpp or cloud APIs."""
if request.node.get_closest_marker("live") or request.node.get_closest_marker("network"):
yield
return
real_connect = socket.socket.connect
real_connect_ex = socket.socket.connect_ex
real_create_connection = socket.create_connection
def guarded_connect(self, address):
_reject_hermetic_connect(address)
return real_connect(self, address)
def guarded_connect_ex(self, address):
_reject_hermetic_connect(address)
return real_connect_ex(self, address)
def guarded_create_connection(address, *args, **kwargs):
_reject_hermetic_connect(address)
return real_create_connection(address, *args, **kwargs)
monkeypatch.setattr(socket.socket, "connect", guarded_connect)
monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect_ex)
monkeypatch.setattr(socket, "create_connection", guarded_create_connection)
yield
def pytest_configure(config):
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment")

View file

@ -11,7 +11,7 @@ import pytest
from antigravity_provider import paths
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
from antigravity_provider.router.preflight_service import PreflightCheckService, PreflightReport
from antigravity_provider.router.preflight_service import PreflightCheckService, PreflightItem, PreflightReport
from antigravity_provider.router.role_registry import CANONICAL_ROLES, RoleRegistry
from antigravity_provider.router.router_config import (
RouterConfig,
@ -75,15 +75,18 @@ def test_dependency_agent_role_registered():
def test_preflight_service_cli_and_environment():
"""Verify CLI tools and environment checks."""
"""Verify CLI tools and environment checks with controlled discovery."""
service = PreflightCheckService.get()
cli_items = service.check_cli_dependencies()
with patch("antigravity_provider.router.preflight_service.shutil.which", return_value="/opt/agy"), \
patch("antigravity_provider.router.preflight_service.importlib.util.find_spec", return_value=object()):
cli_items = service.check_cli_dependencies()
assert len(cli_items) >= 3
ids = {item.check_id for item in cli_items}
assert "cli_agy" in ids
assert "pkg_fastapi" in ids
assert "pkg_uvicorn" in ids
assert all(item.status == "PASS" for item in cli_items)
env_items = service.check_system_environment()
assert len(env_items) >= 3
@ -93,28 +96,63 @@ def test_preflight_service_cli_and_environment():
assert "env_logs_writable" in env_ids
def _fake_preflight_items() -> list:
return [
PreflightItem(check_id="cli_agy", name="CLI", status="PASS", message="mocked"),
PreflightItem(check_id="env_hermes_home", name="HOME", status="PASS", message="mocked"),
PreflightItem(check_id="auth_local-1", name="AUTH", status="WARN", message="mocked"),
PreflightItem(check_id="local_srv_local-1", name="LLM", status="PASS", message="mocked"),
]
def test_preflight_service_run_all_and_action():
"""Verify run_all_checks and action execution."""
"""Verify run_all_checks orchestration without probing this machine."""
service = PreflightCheckService.get()
report = service.run_all_checks()
cli, env, auth, local = (
[_fake_preflight_items()[0]],
[_fake_preflight_items()[1]],
[_fake_preflight_items()[2]],
[_fake_preflight_items()[3]],
)
with patch.object(service, "check_cli_dependencies", return_value=cli) as mock_cli, \
patch.object(service, "check_system_environment", return_value=env) as mock_env, \
patch.object(service, "check_auth_credentials", return_value=auth) as mock_auth, \
patch.object(service, "check_local_servers", return_value=local) as mock_local, \
patch("antigravity_provider.router.preflight_service.urllib.request.urlopen") as mock_urlopen:
report = service.run_all_checks()
action_res = ActionExecutor.execute("run_preflight", {})
mock_cli.assert_called()
mock_env.assert_called()
mock_auth.assert_called()
mock_local.assert_called()
mock_urlopen.assert_not_called()
assert isinstance(report, PreflightReport)
assert isinstance(report.passed_count, int)
assert isinstance(report.failed_count, int)
assert isinstance(report.warn_count, int)
assert len(report.checks) > 0
assert report.passed_count == 3
assert report.warn_count == 1
assert report.failed_count == 0
assert len(report.checks) == 4
report_dict = report.to_dict()
assert "success" in report_dict
assert "checks" in report_dict
assert isinstance(report_dict["checks"], list)
# Test via ActionExecutor
action_res = ActionExecutor.execute("run_preflight", {})
assert "ok" in action_res
assert "message" in action_res
assert "data" in action_res
assert "checks" in action_res["data"]
assert mock_urlopen.call_count == 0
@pytest.mark.live
def test_preflight_live_local_servers():
"""Optional live probe of configured local servers. Not part of hermetic pytest."""
service = PreflightCheckService.get()
items = service.check_local_servers()
assert isinstance(items, list)
assert items
# ============================================================================

View file

@ -123,8 +123,9 @@ def test_launch_native_agy_login_env_isolation(tmp_path, monkeypatch):
mock_popen.assert_called_once()
args, kwargs = mock_popen.call_args
# Command is agy executable
assert args[0][0] == "C:\\fake\\agy.exe"
# Command includes the mocked agy executable (Linux wraps it in a terminal).
launched = args[0]
assert "C:\\fake\\agy.exe" in launched
# Environment points to profile dir
env = kwargs.get("env", {})

View file

@ -56,6 +56,12 @@ def _missing_external(exc: ImportError) -> str | None:
root = name.split(".")[0]
if root in OPTIONAL_EXTERNAL_MODULES:
return root
msg = str(exc)
for mod in sorted(OPTIONAL_EXTERNAL_MODULES, key=len, reverse=True):
if f"No module named '{mod}'" in msg or f'No module named "{mod}"' in msg:
return mod
if f"No module named {mod}" in msg:
return mod
return None
@ -102,7 +108,7 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None:
offenders: list[str] = []
for path in sorted(TESTS_DIR.glob("test_*.py")):
text = path.read_text(encoding="utf-8", errors="ignore")
text = path.read_text(encoding="utf-8-sig", errors="ignore")
try:
tree = ast.parse(text)
except SyntaxError:

View file

@ -218,26 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path):
assert "data" in res
assert res["data"]["update_available"] is False
# 2. check_updates ВСЕГДА синхронна и всегда возвращает данные.
#
# Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это
# закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка
# отвечала «Проверка обновлений запущена» без data, и результат до
# интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не
# могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10
# секунд, ждать её допустимо; в фон уходит только установка.
# check_updates remains synchronous even when async_runner is provided.
dispatched = []
def mock_runner(fn, name):
dispatched.append(name)
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
assert res_async["ok"] is True
assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
mock_chk.assert_called()
# 3. apply_update async
dispatched = []
def mock_runner(fn, name):
dispatched.append(name)
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
assert res_async["ok"] is True
assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
# Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему.
# Важно ровно одно — данные пришли, а не пустой ответ «запущено».
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
# 3. apply_update async
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
assert res_apply_async["ok"] is True
assert "ApplyUpdate" in dispatched

View file

@ -173,30 +173,38 @@ def test_e_repeated_open_browser_invariance(tmp_path, monkeypatch, tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try:
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url
orig_session_id = wizard.grok_session_id
orig_url = wizard.grok_url
session = get_grok_oauth_session(orig_session_id)
session = get_grok_oauth_session(orig_session_id)
with patch("webbrowser.open") as mock_open:
wizard._open_grok_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
with patch("webbrowser.open") as mock_open:
wizard._open_grok_browser()
wizard._open_grok_browser()
wizard._open_grok_browser()
assert mock_open.call_count == 3
for call in mock_open.call_args_list:
assert call[0][0] == orig_url
assert mock_open.call_count == 3
for call in mock_open.call_args_list:
assert call[0][0] == orig_url
assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url
assert wizard.grok_session_id == orig_session_id
assert wizard.grok_url == orig_url
wizard.destroy()
wizard.destroy()
finally:
root.destroy()
@ -211,21 +219,28 @@ def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
root = ctk.CTkToplevel(tk_root)
root.withdraw()
fake_session = MagicMock()
fake_session.status = "pending"
fake_session.is_dev_mode = False
fake_session.error_msg = None
grok_url = "https://accounts.x.ai/sign-in?user_code=ABCD"
try:
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
wizard = AddAccountWizard(root)
wizard.selected_provider = "grok"
wizard.target_slot = "grok-worker-1"
wizard._show_step_2_auth()
wizard._polling_active = False
assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
assert wizard.grok_url is not None
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
# Copy without opening browser
wizard._copy_grok_url()
clipboard_content = wizard.clipboard_get()
assert clipboard_content == wizard.grok_url
wizard._copy_grok_url()
clipboard_content = wizard.clipboard_get()
assert clipboard_content == wizard.grok_url
wizard.destroy()
wizard.destroy()
finally:
root.destroy()

View file

@ -1,15 +1,14 @@
"""End-to-end verification for Grok and Claude connection, assignment, and testing."""
"""End-to-end verification for Grok and Claude connection, assignment, and testing."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router import action_handler
from antigravity_provider.router.action_handler import do_test_profile
from antigravity_provider.router.auto_assigner import AutoAssigner, ensure_profile_in_routing
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
from antigravity_provider.router.hermes_hub_app import do_test_profile
@pytest.mark.unit
@ -37,9 +36,8 @@ def test_grok_wizard_definition_and_routing_flow():
roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
)
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
assert ok_def, f"Definition failed: {msg_def}"
assert "grok-worker-1" in config.profiles
@ -58,9 +56,8 @@ def test_claude_wizard_definition_and_routing_flow():
roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
)
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True), \
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
patch("antigravity_provider.router.auto_assigner.save_router_config", return_value=True):
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
assert ok_def, f"Definition failed: {msg_def}"
assert "claude-orch" in config.profiles
@ -80,7 +77,7 @@ def test_do_test_profile_for_grok_and_claude():
"claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]),
}
)
mock_adapter = MagicMock()
mock_adapter.health_check.return_value = True
@ -88,7 +85,7 @@ def test_do_test_profile_for_grok_and_claude():
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
res_grok = do_test_profile("grok", "grok-worker-1")
assert res_grok["success"] is True
assert res_grok["model"] == "grok-beta"

View file

@ -117,6 +117,7 @@ def test_identity_priority_and_plan_badge_suppression() -> None:
@pytest.mark.ui
@pytest.mark.timeout(90)
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
view = AccountsView(ui_root)
try:

View file

@ -133,18 +133,20 @@ def test_graph_store_handles_twenty_nodes(tmp_path):
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
from antigravity_provider.router import auto_assigner as assigner_module
config = _config()
calls = []
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
monkeypatch.setattr(assigner_module, "load_router_config", lambda: config)
monkeypatch.setattr(
wizard_module.AutoAssigner,
assigner_module.AutoAssigner,
"assign_profile_to_role",
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
)
assert wizard_module.ensure_profile_in_routing("orch")[0]
assert calls == []
monkeypatch.setattr(
wizard_module.AutoAssigner,
assigner_module.AutoAssigner,
"get_display_name_and_role",
lambda _profile: ("Новый кодер", "coder", "fallback"),
)

View file

@ -134,7 +134,10 @@ def test_live_cycle_stops_with_explicit_iteration_limit_event(workflow_service,
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine())
workflow_service.start("Проверить реальный цикл")
workflow_service._thread.join(timeout=3)
thread = workflow_service._thread
assert thread is not None
thread.join(timeout=3)
assert not thread.is_alive(), "Workflow execution thread leaked after join"
assert workflow_service.run["status"] == "failed"
assert workflow_service.run["error"] == "Достигнут предел итераций: 2"
@ -155,7 +158,10 @@ def test_provider_error_text_reaches_run_and_events(workflow_service, monkeypatc
workflow_service.workflow.start_agent_id = "developer"
workflow_service.workflow.edges = []
workflow_service.start("Проверить ошибку")
workflow_service._thread.join(timeout=3)
thread = workflow_service._thread
assert thread is not None
thread.join(timeout=3)
assert not thread.is_alive(), "Workflow execution thread leaked after join"
assert workflow_service.run["status"] == "failed"
assert provider_text in workflow_service.run["error"]