From b4ae08ef53988898c73a8af6db58d890c8d451b1 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:03:37 +0000 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 2 +- pyproject.toml | 3 + scripts/run_hermetic_tests.py | 82 ++++++ scripts/test_hang_diagnose.py | 272 ++++++++++++++++++ .../router/auto_assigner.py | 16 ++ .../router/ui/add_account_wizard.py | 15 +- tests/conftest.py | 61 ++++ .../test_a31_preflight_state_batching_pii.py | 62 +++- tests/test_agy_native_login.py | 5 +- tests/test_import_invariants.py | 8 +- tests/test_in_app_updates_a27.py | 30 +- tests/test_oauth_lifecycle.py | 71 +++-- tests/test_ui_claude_grok_connection.py | 23 +- tests/test_ui_phase2_6.py | 1 + tests/test_ui_routing_graph.py | 8 +- tests/test_workflow_service_a30.py | 10 +- 17 files changed, 581 insertions(+), 94 deletions(-) create mode 100644 scripts/run_hermetic_tests.py create mode 100644 scripts/test_hang_diagnose.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de24955..d8ca643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a6660f..c4b00ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/pyproject.toml b/pyproject.toml index 4c539db..3675167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/run_hermetic_tests.py b/scripts/run_hermetic_tests.py new file mode 100644 index 0000000..7d35538 --- /dev/null +++ b/scripts/run_hermetic_tests.py @@ -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()) diff --git a/scripts/test_hang_diagnose.py b/scripts/test_hang_diagnose.py new file mode 100644 index 0000000..05b67dd --- /dev/null +++ b/scripts/test_hang_diagnose.py @@ -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()) diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 662e677..42a1c52 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -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") diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index b636f68..e1e5bdf 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -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.""" diff --git a/tests/conftest.py b/tests/conftest.py index 8e913bb..7d13727 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/test_a31_preflight_state_batching_pii.py b/tests/test_a31_preflight_state_batching_pii.py index cbe35a0..02e1b85 100644 --- a/tests/test_a31_preflight_state_batching_pii.py +++ b/tests/test_a31_preflight_state_batching_pii.py @@ -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 # ============================================================================ diff --git a/tests/test_agy_native_login.py b/tests/test_agy_native_login.py index 15f3299..9abe070 100644 --- a/tests/test_agy_native_login.py +++ b/tests/test_agy_native_login.py @@ -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", {}) diff --git a/tests/test_import_invariants.py b/tests/test_import_invariants.py index bcc1ddf..20849aa 100644 --- a/tests/test_import_invariants.py +++ b/tests/test_import_invariants.py @@ -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: diff --git a/tests/test_in_app_updates_a27.py b/tests/test_in_app_updates_a27.py index 3f09cbc..814cc27 100644 --- a/tests/test_in_app_updates_a27.py +++ b/tests/test_in_app_updates_a27.py @@ -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 diff --git a/tests/test_oauth_lifecycle.py b/tests/test_oauth_lifecycle.py index 584914e..994b142 100644 --- a/tests/test_oauth_lifecycle.py +++ b/tests/test_oauth_lifecycle.py @@ -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() diff --git a/tests/test_ui_claude_grok_connection.py b/tests/test_ui_claude_grok_connection.py index 340fc1f..ec68e22 100644 --- a/tests/test_ui_claude_grok_connection.py +++ b/tests/test_ui_claude_grok_connection.py @@ -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" diff --git a/tests/test_ui_phase2_6.py b/tests/test_ui_phase2_6.py index 9a8cf43..b8dc033 100644 --- a/tests/test_ui_phase2_6.py +++ b/tests/test_ui_phase2_6.py @@ -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: diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py index eedd20d..7563138 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -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"), ) diff --git a/tests/test_workflow_service_a30.py b/tests/test_workflow_service_a30.py index b1400cf..8948b35 100644 --- a/tests/test_workflow_service_a30.py +++ b/tests/test_workflow_service_a30.py @@ -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"]