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:
parent
59d57a41ef
commit
b4ae08ef53
17 changed files with 581 additions and 94 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
82
scripts/run_hermetic_tests.py
Normal file
82
scripts/run_hermetic_tests.py
Normal 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())
|
||||
272
scripts/test_hang_diagnose.py
Normal file
272
scripts/test_hang_diagnose.py
Normal 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())
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue