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:
|
test:
|
||||||
name: Clean Windows Runner Test
|
name: Clean Windows Runner Test
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|
@ -24,7 +25,7 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e .[dev]
|
pip install -e ".[dev,web]"
|
||||||
|
|
||||||
- name: Code Quality (ruff)
|
- name: Code Quality (ruff)
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -41,6 +42,7 @@ jobs:
|
||||||
headless:
|
headless:
|
||||||
name: Headless Run (no GUI dependencies)
|
name: Headless Run (no GUI dependencies)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -54,7 +56,7 @@ jobs:
|
||||||
- name: Install dependencies without GUI extras
|
- name: Install dependencies without GUI extras
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e .[dev]
|
pip install -e ".[dev,web]"
|
||||||
pip uninstall -y customtkinter
|
pip uninstall -y customtkinter
|
||||||
|
|
||||||
# A test module importing customtkinter at module scope aborts collection
|
# 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
|
- name: Install dependencies & dev tools
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -e .[dev]
|
pip install -e ".[dev,web]"
|
||||||
|
|
||||||
- name: Run Release Gate Check
|
- name: Run Release Gate Check
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
"pytest-asyncio>=0.23.0",
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-timeout>=2.3.0",
|
||||||
"anyio>=4.0.0",
|
"anyio>=4.0.0",
|
||||||
"ruff>=0.3.0",
|
"ruff>=0.3.0",
|
||||||
]
|
]
|
||||||
|
|
@ -65,6 +66,8 @@ testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src"]
|
||||||
python_files = ["test_*.py"]
|
python_files = ["test_*.py"]
|
||||||
addopts = "-m 'not live and not network and not installer'"
|
addopts = "-m 'not live and not network and not installer'"
|
||||||
|
timeout = 30
|
||||||
|
timeout_method = "thread"
|
||||||
markers = [
|
markers = [
|
||||||
"unit: Unit tests that run isolated in-memory",
|
"unit: Unit tests that run isolated in-memory",
|
||||||
"integration: Component integration tests with isolated filesystem",
|
"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",
|
level="info",
|
||||||
)
|
)
|
||||||
return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}"
|
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.theme import Theme
|
||||||
from antigravity_provider.router.ui.components import HubButton, HubCard, HubEntry, HubModal
|
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.profile_manager import ProfileAuthManager
|
||||||
from antigravity_provider.router.router_config import load_router_config
|
from antigravity_provider.router.router_config import load_router_config
|
||||||
from antigravity_provider.router.unified_health import EventLogService
|
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):
|
class AddAccountWizard(HubModal):
|
||||||
"""4-Step Add Account Wizard with OAuth / API Key support and Auto-Assignment."""
|
"""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.
|
1. Zero modification to real user credentials or router_profiles.yaml.
|
||||||
2. Complete filesystem sandboxing in temporary directory via HERMES_HOME.
|
2. Complete filesystem sandboxing in temporary directory via HERMES_HOME.
|
||||||
3. Offline execution for default test runs (network / live require explicit -m markers).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
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):
|
if str(REPO_SRC) not in sys.path or sys.path[0] != str(REPO_SRC):
|
||||||
sys.path.insert(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)
|
@pytest.fixture(autouse=True)
|
||||||
def isolate_hermes_environment(tmp_path, monkeypatch):
|
def isolate_hermes_environment(tmp_path, monkeypatch):
|
||||||
|
|
@ -35,6 +40,62 @@ def isolate_hermes_environment(tmp_path, monkeypatch):
|
||||||
yield temp_hermes
|
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):
|
def pytest_configure(config):
|
||||||
config.addinivalue_line("markers", "ui: mark test as requiring CustomTkinter / Tk graphical environment")
|
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 import paths
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
from antigravity_provider.router.action_handler import ActionExecutor
|
||||||
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
|
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.role_registry import CANONICAL_ROLES, RoleRegistry
|
||||||
from antigravity_provider.router.router_config import (
|
from antigravity_provider.router.router_config import (
|
||||||
RouterConfig,
|
RouterConfig,
|
||||||
|
|
@ -75,15 +75,18 @@ def test_dependency_agent_role_registered():
|
||||||
|
|
||||||
|
|
||||||
def test_preflight_service_cli_and_environment():
|
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()
|
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
|
assert len(cli_items) >= 3
|
||||||
ids = {item.check_id for item in cli_items}
|
ids = {item.check_id for item in cli_items}
|
||||||
assert "cli_agy" in ids
|
assert "cli_agy" in ids
|
||||||
assert "pkg_fastapi" in ids
|
assert "pkg_fastapi" in ids
|
||||||
assert "pkg_uvicorn" in ids
|
assert "pkg_uvicorn" in ids
|
||||||
|
assert all(item.status == "PASS" for item in cli_items)
|
||||||
|
|
||||||
env_items = service.check_system_environment()
|
env_items = service.check_system_environment()
|
||||||
assert len(env_items) >= 3
|
assert len(env_items) >= 3
|
||||||
|
|
@ -93,28 +96,63 @@ def test_preflight_service_cli_and_environment():
|
||||||
assert "env_logs_writable" in env_ids
|
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():
|
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()
|
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, PreflightReport)
|
||||||
assert isinstance(report.passed_count, int)
|
assert report.passed_count == 3
|
||||||
assert isinstance(report.failed_count, int)
|
assert report.warn_count == 1
|
||||||
assert isinstance(report.warn_count, int)
|
assert report.failed_count == 0
|
||||||
assert len(report.checks) > 0
|
assert len(report.checks) == 4
|
||||||
|
|
||||||
report_dict = report.to_dict()
|
report_dict = report.to_dict()
|
||||||
assert "success" in report_dict
|
assert "success" in report_dict
|
||||||
assert "checks" in report_dict
|
assert "checks" in report_dict
|
||||||
assert isinstance(report_dict["checks"], list)
|
assert isinstance(report_dict["checks"], list)
|
||||||
|
|
||||||
# Test via ActionExecutor
|
|
||||||
action_res = ActionExecutor.execute("run_preflight", {})
|
|
||||||
assert "ok" in action_res
|
assert "ok" in action_res
|
||||||
assert "message" in action_res
|
assert "message" in action_res
|
||||||
assert "data" in action_res
|
assert "data" in action_res
|
||||||
assert "checks" in action_res["data"]
|
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()
|
mock_popen.assert_called_once()
|
||||||
args, kwargs = mock_popen.call_args
|
args, kwargs = mock_popen.call_args
|
||||||
|
|
||||||
# Command is agy executable
|
# Command includes the mocked agy executable (Linux wraps it in a terminal).
|
||||||
assert args[0][0] == "C:\\fake\\agy.exe"
|
launched = args[0]
|
||||||
|
assert "C:\\fake\\agy.exe" in launched
|
||||||
|
|
||||||
# Environment points to profile dir
|
# Environment points to profile dir
|
||||||
env = kwargs.get("env", {})
|
env = kwargs.get("env", {})
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ def _missing_external(exc: ImportError) -> str | None:
|
||||||
root = name.split(".")[0]
|
root = name.split(".")[0]
|
||||||
if root in OPTIONAL_EXTERNAL_MODULES:
|
if root in OPTIONAL_EXTERNAL_MODULES:
|
||||||
return root
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,7 +108,7 @@ def test_gui_test_modules_guard_optional_ui_dependency() -> None:
|
||||||
|
|
||||||
offenders: list[str] = []
|
offenders: list[str] = []
|
||||||
for path in sorted(TESTS_DIR.glob("test_*.py")):
|
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:
|
try:
|
||||||
tree = ast.parse(text)
|
tree = ast.parse(text)
|
||||||
except SyntaxError:
|
except SyntaxError:
|
||||||
|
|
|
||||||
|
|
@ -218,26 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path):
|
||||||
assert "data" in res
|
assert "data" in res
|
||||||
assert res["data"]["update_available"] is False
|
assert res["data"]["update_available"] is False
|
||||||
|
|
||||||
# 2. check_updates ВСЕГДА синхронна и всегда возвращает данные.
|
# check_updates remains synchronous even when async_runner is provided.
|
||||||
#
|
dispatched = []
|
||||||
# Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это
|
|
||||||
# закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка
|
def mock_runner(fn, name):
|
||||||
# отвечала «Проверка обновлений запущена» без data, и результат до
|
dispatched.append(name)
|
||||||
# интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не
|
|
||||||
# могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10
|
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 = []
|
dispatched = []
|
||||||
|
|
||||||
def mock_runner(fn, name):
|
def mock_runner(fn, name):
|
||||||
dispatched.append(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)
|
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
|
||||||
assert res_apply_async["ok"] is True
|
assert res_apply_async["ok"] is True
|
||||||
assert "ApplyUpdate" in dispatched
|
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 = ctk.CTkToplevel(tk_root)
|
||||||
root.withdraw()
|
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:
|
try:
|
||||||
wizard = AddAccountWizard(root)
|
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
|
||||||
wizard.selected_provider = "grok"
|
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
|
||||||
wizard.target_slot = "grok-worker-1"
|
wizard = AddAccountWizard(root)
|
||||||
wizard._show_step_2_auth()
|
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_session_id = wizard.grok_session_id
|
||||||
orig_url = wizard.grok_url
|
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:
|
with patch("webbrowser.open") as mock_open:
|
||||||
wizard._open_grok_browser()
|
wizard._open_grok_browser()
|
||||||
wizard._open_grok_browser()
|
wizard._open_grok_browser()
|
||||||
wizard._open_grok_browser()
|
wizard._open_grok_browser()
|
||||||
|
|
||||||
assert mock_open.call_count == 3
|
assert mock_open.call_count == 3
|
||||||
for call in mock_open.call_args_list:
|
for call in mock_open.call_args_list:
|
||||||
assert call[0][0] == orig_url
|
assert call[0][0] == orig_url
|
||||||
|
|
||||||
assert wizard.grok_session_id == orig_session_id
|
assert wizard.grok_session_id == orig_session_id
|
||||||
assert wizard.grok_url == orig_url
|
assert wizard.grok_url == orig_url
|
||||||
|
|
||||||
wizard.destroy()
|
wizard.destroy()
|
||||||
finally:
|
finally:
|
||||||
root.destroy()
|
root.destroy()
|
||||||
|
|
||||||
|
|
@ -211,21 +219,28 @@ def test_f_copy_before_open_browser(tmp_path, monkeypatch, tk_root):
|
||||||
|
|
||||||
root = ctk.CTkToplevel(tk_root)
|
root = ctk.CTkToplevel(tk_root)
|
||||||
root.withdraw()
|
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:
|
try:
|
||||||
wizard = AddAccountWizard(root)
|
with patch("antigravity_provider.router.grok_oauth.start_grok_oauth", return_value=("sid", grok_url, "ABCD")), \
|
||||||
wizard.selected_provider = "grok"
|
patch("antigravity_provider.router.grok_oauth.get_grok_oauth_session", return_value=fake_session):
|
||||||
wizard.target_slot = "grok-worker-1"
|
wizard = AddAccountWizard(root)
|
||||||
wizard._show_step_2_auth()
|
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 wizard.grok_url is not None
|
||||||
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
|
assert "x.ai" in wizard.grok_url or "accounts" in wizard.grok_url
|
||||||
|
|
||||||
# Copy without opening browser
|
wizard._copy_grok_url()
|
||||||
wizard._copy_grok_url()
|
clipboard_content = wizard.clipboard_get()
|
||||||
clipboard_content = wizard.clipboard_get()
|
assert clipboard_content == wizard.grok_url
|
||||||
assert clipboard_content == wizard.grok_url
|
|
||||||
|
|
||||||
wizard.destroy()
|
wizard.destroy()
|
||||||
finally:
|
finally:
|
||||||
root.destroy()
|
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 __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
from antigravity_provider.router.action_handler import do_test_profile
|
||||||
from antigravity_provider.router import action_handler
|
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.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
|
@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=[])},
|
roles={"developer-1": RolePolicy(role_name="developer-1", preferred_chain=[])},
|
||||||
)
|
)
|
||||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
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.auto_assigner.save_router_config", return_value=True):
|
||||||
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
|
|
||||||
|
|
||||||
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
|
ok_def, msg_def = AutoAssigner.ensure_profile_definition("grok", "grok-worker-1")
|
||||||
assert ok_def, f"Definition failed: {msg_def}"
|
assert ok_def, f"Definition failed: {msg_def}"
|
||||||
assert "grok-worker-1" in config.profiles
|
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=[])},
|
roles={"manager": RolePolicy(role_name="manager", preferred_chain=[])},
|
||||||
)
|
)
|
||||||
with patch("antigravity_provider.router.auto_assigner.load_router_config", return_value=config), \
|
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.auto_assigner.save_router_config", return_value=True):
|
||||||
patch("antigravity_provider.router.ui.add_account_wizard.load_router_config", return_value=config):
|
|
||||||
|
|
||||||
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
|
ok_def, msg_def = AutoAssigner.ensure_profile_definition("claude", "claude-orch")
|
||||||
assert ok_def, f"Definition failed: {msg_def}"
|
assert ok_def, f"Definition failed: {msg_def}"
|
||||||
assert "claude-orch" in config.profiles
|
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"]),
|
"claude-orch": RouterProfileConfig(profile_id="claude-orch", provider="claude", preferred_models=["claude-3-5-sonnet"]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_adapter = MagicMock()
|
mock_adapter = MagicMock()
|
||||||
mock_adapter.health_check.return_value = True
|
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.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.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
|
||||||
patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
|
patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
|
||||||
|
|
||||||
res_grok = do_test_profile("grok", "grok-worker-1")
|
res_grok = do_test_profile("grok", "grok-worker-1")
|
||||||
assert res_grok["success"] is True
|
assert res_grok["success"] is True
|
||||||
assert res_grok["model"] == "grok-beta"
|
assert res_grok["model"] == "grok-beta"
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ def test_identity_priority_and_plan_badge_suppression() -> None:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.ui
|
@pytest.mark.ui
|
||||||
|
@pytest.mark.timeout(90)
|
||||||
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
|
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
|
||||||
view = AccountsView(ui_root)
|
view = AccountsView(ui_root)
|
||||||
try:
|
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):
|
def test_wizard_keeps_existing_chain_rank_and_assigns_missing_slot(monkeypatch):
|
||||||
|
from antigravity_provider.router import auto_assigner as assigner_module
|
||||||
|
|
||||||
config = _config()
|
config = _config()
|
||||||
calls = []
|
calls = []
|
||||||
monkeypatch.setattr(wizard_module, "load_router_config", lambda: config)
|
monkeypatch.setattr(assigner_module, "load_router_config", lambda: config)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
wizard_module.AutoAssigner,
|
assigner_module.AutoAssigner,
|
||||||
"assign_profile_to_role",
|
"assign_profile_to_role",
|
||||||
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
lambda profile, role, is_primary: calls.append((profile, role, is_primary)) or (True, "ok"),
|
||||||
)
|
)
|
||||||
assert wizard_module.ensure_profile_in_routing("orch")[0]
|
assert wizard_module.ensure_profile_in_routing("orch")[0]
|
||||||
assert calls == []
|
assert calls == []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
wizard_module.AutoAssigner,
|
assigner_module.AutoAssigner,
|
||||||
"get_display_name_and_role",
|
"get_display_name_and_role",
|
||||||
lambda _profile: ("Новый кодер", "coder", "fallback"),
|
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())
|
monkeypatch.setattr("antigravity_provider.router.router_engine.get_router_engine", lambda: FakeEngine())
|
||||||
workflow_service.start("Проверить реальный цикл")
|
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["status"] == "failed"
|
||||||
assert workflow_service.run["error"] == "Достигнут предел итераций: 2"
|
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.start_agent_id = "developer"
|
||||||
workflow_service.workflow.edges = []
|
workflow_service.workflow.edges = []
|
||||||
workflow_service.start("Проверить ошибку")
|
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["status"] == "failed"
|
||||||
assert provider_text in workflow_service.run["error"]
|
assert provider_text in workflow_service.run["error"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue