"""Shared utilities for M1-M6 E2E verification helpers. Provides subprocess-based CLI invocation, workspace setup, and common assertions for real (non-mocked) end-to-end tests. """ from __future__ import annotations import contextlib import json import os import shutil import subprocess import sys import tempfile from pathlib import Path def run_cli( *args: str, workspace: str, env_extra: dict[str, str] | None = None, timeout: int = 120, ) -> subprocess.CompletedProcess[str]: """Run ``python -m cleveragents `` in a subprocess. Uses the same Python interpreter as the running process. Inherits ``CLEVERAGENTS_HOME``, ``CLEVERAGENTS_AUTO_APPLY_MIGRATIONS``, and ``CLEVERAGENTS_TESTING_USE_MOCK_AI`` from the environment (set by ``robot/common.resource`` ``Setup Test Environment``). Parameters ---------- *args: CLI arguments (e.g. ``"action", "create", "--config", path``). workspace: Working directory for the subprocess. env_extra: Additional environment variables to merge. timeout: Subprocess timeout in seconds. """ env = os.environ.copy() # Ensure test-critical variables are set env.setdefault("CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "true") env.setdefault("CLEVERAGENTS_TESTING_USE_MOCK_AI", "true") env.setdefault("NO_COLOR", "1") if env_extra: env.update(env_extra) return subprocess.run( [sys.executable, "-m", "cleveragents", *args], cwd=workspace, capture_output=True, text=True, timeout=timeout, env=env, ) def run_cli_json( *args: str, workspace: str, env_extra: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], dict | list | None]: """Run CLI command with ``--format json`` and parse output. Returns the completed process and parsed JSON (or ``None``). """ full_args = [*args, "--format", "json"] result = run_cli(*full_args, workspace=workspace, env_extra=env_extra) parsed: dict | list | None = None if result.returncode == 0 and result.stdout.strip(): with contextlib.suppress(json.JSONDecodeError): parsed = json.loads(result.stdout) return result, parsed def setup_workspace(prefix: str = "e2e_") -> str: """Create an isolated workspace directory with a ready database. Sets ``CLEVERAGENTS_HOME`` and ``CLEVERAGENTS_DATABASE_URL`` to the workspace so all tables are available for every CLI command. When a pre-migrated template database is available (either via the ``CLEVERAGENTS_TEMPLATE_DB`` environment variable or at the default ``build/.template-migrated.db`` path), the template is copied instead of running full Alembic migrations. This reduces per-test setup from ~1-3 s to ~1 ms, which is critical for parallel execution under pabot where many workers set up workspaces concurrently. Returns the absolute path to the workspace. """ workspace = tempfile.mkdtemp(prefix=prefix) os.environ["CLEVERAGENTS_HOME"] = workspace db_path = os.path.join(workspace, "cleveragents_e2e.db") db_url = f"sqlite:///{db_path}" os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url # Fast path: copy pre-migrated template DB instead of running # 25+ Alembic migrations (avoids I/O contention under pabot). template = os.environ.get("CLEVERAGENTS_TEMPLATE_DB") if not template: default_template = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "build", ".template-migrated.db", ) if os.path.isfile(default_template): template = default_template if template and os.path.isfile(template): shutil.copy2(template, db_path) else: # Fallback: run Alembic migrations (slow path). from cleveragents.infrastructure.database.migration_runner import ( MigrationRunner, ) runner = MigrationRunner(db_url) runner.init_or_upgrade(require_confirmation=False) return workspace def cleanup_workspace(workspace: str) -> None: """Remove a workspace directory and unset env vars, ignoring errors.""" shutil.rmtree(workspace, ignore_errors=True) os.environ.pop("CLEVERAGENTS_HOME", None) os.environ.pop("CLEVERAGENTS_DATABASE_URL", None) def fail(msg: str) -> None: """Print failure message and exit with code 1.""" print(f"FAIL: {msg}", file=sys.stderr) raise SystemExit(1) def is_expected_provider_unavailable(output: str) -> bool: """Return True when output matches known provider-unavailable failures.""" hay = output.lower() patterns = ( "provider openai is not configured", "provider 'openai' is not configured", "openai_api_key is not set", "openai_api_key not found", "no provider configured", ) return any(pattern in hay for pattern in patterns) def write_yaml(content: str) -> str: """Write YAML content to a temporary file and return its path.""" fd, path = tempfile.mkstemp(suffix=".yaml") with os.fdopen(fd, "w") as fh: fh.write(content) return path def init_bare_git_repo() -> str: """Create a bare git repository with an initial commit. Returns the path to the repository. """ repo_dir = tempfile.mkdtemp(prefix="e2e_git_") cmds = [ ["git", "init"], ["git", "config", "user.email", "test@example.com"], ["git", "config", "user.name", "Test"], ] for cmd in cmds: subprocess.run(cmd, cwd=repo_dir, capture_output=True, check=True) readme = Path(repo_dir) / "README.md" readme.write_text("# Test repo\n") subprocess.run( ["git", "add", "."], cwd=repo_dir, capture_output=True, check=True, ) subprocess.run( ["git", "commit", "-m", "Initial commit"], cwd=repo_dir, capture_output=True, check=True, ) return repo_dir