"""Step definitions for CLI global options: --data-dir, --config-path, -v. Tests for issue #6785 — spec-required global options that were missing from main_callback() and caused fatal "No such option" crashes. All step patterns use the prefix "global options" to avoid any ambiguity with the many generic CLI step definitions in other step files. """ from __future__ import annotations import os import re import shutil import tempfile from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from typer.testing import CliRunner from cleveragents.cli.formatting import OutputFormat from cleveragents.cli.main import app # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- # Regex to strip ANSI escape sequences (CSI sequences and OSC sequences). # Used to make output assertions environment-agnostic — Rich may emit ANSI # colour/style codes when the CI environment does not set TERM=dumb. _ANSI_ESCAPE_RE = re.compile( r"\x1b(?:" r"\[[0-9;]*[a-zA-Z]" # CSI sequences (SGR, cursor, etc.) r"|" r"\][^\x07]*\x07" # OSC sequences (hyperlink, title, etc.) r")" ) def _strip_ansi(text: str) -> str: """Remove ANSI escape sequences from *text*.""" return _ANSI_ESCAPE_RE.sub("", text) def _invoke(args: list[str]) -> Any: """Invoke the Typer app with the given args and return the result.""" runner = CliRunner() return runner.invoke(app, args, catch_exceptions=True) def _combined_output(result: Any) -> str: """Return combined output from a CliRunner result, with ANSI codes stripped. In non-TTY environments (CI), Typer/Rich may split output between stdout and stderr. Check both explicitly so that ``--help`` output assertions work regardless of the terminal detection. """ stdout = _strip_ansi((getattr(result, "stdout", "") or "").strip()) stderr = _strip_ansi((getattr(result, "stderr", "") or "").strip()) combined = ( (stdout + "\n" + stderr).strip() if (stdout and stderr) else (stdout or stderr) ) return combined or _strip_ansi((result.output or "").strip()) # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given("global options test env is clean") def step_global_opts_env_clean(context: Context) -> None: """Ensure test-specific env vars are not polluted from previous tests. Registers a cleanup handler that restores the original env var values after the scenario ends. The global ``after_scenario`` hook in ``features/environment.py`` runs all ``context._cleanup_handlers``. """ orig_data_dir = os.environ.get("CLEVERAGENTS_DATA_DIR") orig_config_path = os.environ.get("CLEVERAGENTS_CONFIG_PATH") # Clean up any leftover from previous scenarios for var in ("CLEVERAGENTS_DATA_DIR", "CLEVERAGENTS_CONFIG_PATH"): os.environ.pop(var, None) # Register restoration handler so that env vars set by main_callback() # during CLI invocation do not pollute subsequent scenarios. def _restore() -> None: for key, val in [ ("CLEVERAGENTS_DATA_DIR", orig_data_dir), ("CLEVERAGENTS_CONFIG_PATH", orig_config_path), ]: if val is not None: os.environ[key] = val else: os.environ.pop(key, None) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(_restore) @given("a temp data dir is prepared") def step_temp_data_dir_prepared(context: Context) -> None: """Create a real temporary directory for --data-dir testing. Registers a cleanup handler so the directory is removed after the scenario. """ tmp_dir = tempfile.mkdtemp(prefix="ca_test_data_dir_") context.temp_data_dir = tmp_dir if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: shutil.rmtree(tmp_dir, ignore_errors=True)) @given("a temp config file is prepared") def step_temp_config_file_prepared(context: Context) -> None: """Create a real temporary TOML config file for --config-path testing. Registers a cleanup handler so the file is removed after the scenario. """ with tempfile.NamedTemporaryFile( suffix=".toml", prefix="ca_test_config_", delete=False ) as tmp_file: tmp_file.write(b"# test config\n") tmp_path = tmp_file.name context.temp_config_file = tmp_path if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: os.unlink(tmp_path)) @given("a temp file path is prepared") def step_temp_file_path_prepared(context: Context) -> None: """Create a temporary FILE (not directory) to trigger --data-dir validation. Registers a cleanup handler so the file is removed after the scenario. """ with tempfile.NamedTemporaryFile(prefix="ca_test_file_", delete=False) as tmp_file: tmp_path = tmp_file.name context.temp_file_path = tmp_path if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: os.unlink(tmp_path)) # --------------------------------------------------------------------------- # When steps - CLI invocation # --------------------------------------------------------------------------- @when('I run the global options CLI with data-dir flag and "{subcommand}"') def step_run_global_opts_data_dir(context: Context, subcommand: str) -> None: """Run the CLI with --data-dir pointing at the temp directory.""" args = ["--data-dir", context.temp_data_dir, subcommand] result = _invoke(args) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) @when('I run the global options CLI with config-path flag and "{subcommand}"') def step_run_global_opts_config_path(context: Context, subcommand: str) -> None: """Run the CLI with --config-path pointing at the temp config file.""" args = ["--config-path", context.temp_config_file, subcommand] result = _invoke(args) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) @when('I run the global options CLI with both path flags and "{subcommand}"') def step_run_global_opts_both(context: Context, subcommand: str) -> None: """Run the CLI with both --data-dir and --config-path.""" args = [ "--data-dir", context.temp_data_dir, "--config-path", context.temp_config_file, subcommand, ] result = _invoke(args) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) @when('I run the global options CLI with data-dir as temp file and "{subcommand}"') def step_run_global_opts_data_dir_file(context: Context, subcommand: str) -> None: """Run CLI with --data-dir pointing to a FILE (triggers validation error).""" args = ["--data-dir", context.temp_file_path, subcommand] result = _invoke(args) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) @when('I run the global options CLI with invalid config-path and "{subcommand}"') def step_run_global_opts_invalid_config_path(context: Context, subcommand: str) -> None: """Run CLI with --config-path pointing to a non-existent file.""" args = ["--config-path", "/tmp/no_such_file_xyz_99999.toml", subcommand] result = _invoke(args) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) @when('I run the global options CLI with args "{args}"') def step_run_global_opts_with_args(context: Context, args: str) -> None: """Run the CLI with space-separated args string.""" result = _invoke(args.split()) context.global_opts_result = result context.global_opts_exit_code = result.exit_code context.global_opts_output = _combined_output(result) # --------------------------------------------------------------------------- # When steps - direct callback testing for verbosity # --------------------------------------------------------------------------- @when("I call main_callback with verbosity {count:d}") def step_call_main_callback_verbosity(context: Context, count: int) -> None: """Invoke main_callback directly, capturing the log level configured.""" from cleveragents.cli.main import main_callback context.global_opts_captured_log_level: str | None = None context.global_opts_captured_ctx_obj: dict[str, Any] = {} def _capture_log_level( *, env: str = "development", log_level: str = "INFO" ) -> None: context.global_opts_captured_log_level = log_level mock_ctx = MagicMock() mock_ctx.obj = {} with patch( "cleveragents.config.logging.configure_structlog", side_effect=_capture_log_level, ): main_callback( ctx=mock_ctx, version=None, # type: ignore[arg-type] show_secrets=False, fmt=OutputFormat.RICH, data_dir=None, config_path=None, verbose=count, ) context.global_opts_captured_ctx_obj = mock_ctx.obj # --------------------------------------------------------------------------- # Then steps - exit codes # --------------------------------------------------------------------------- @then("the global options CLI should succeed") def step_global_opts_cli_succeed(context: Context) -> None: actual = context.global_opts_exit_code output = context.global_opts_output assert actual == 0, f"Expected exit code 0, got {actual}.\nOutput:\n{output}" @then("the global options CLI should fail") def step_global_opts_cli_fail(context: Context) -> None: actual = context.global_opts_exit_code assert actual != 0, ( f"Expected non-zero exit code, got {actual}.\nOutput:\n{context.global_opts_output}" ) # --------------------------------------------------------------------------- # Then steps - env var assertions # --------------------------------------------------------------------------- @then("the CLEVERAGENTS_DATA_DIR env var should match the temp data dir") def step_env_data_dir_matches(context: Context) -> None: """Verify that --data-dir set CLEVERAGENTS_DATA_DIR to the temp directory.""" env_val = os.environ.get("CLEVERAGENTS_DATA_DIR") expected = str(Path(context.temp_data_dir).resolve()) assert env_val is not None, ( "CLEVERAGENTS_DATA_DIR was not set after --data-dir invocation" ) assert str(Path(env_val).resolve()) == expected, ( f"CLEVERAGENTS_DATA_DIR={env_val!r} does not match expected {expected!r}" ) @then("the CLEVERAGENTS_CONFIG_PATH env var should match the temp config file") def step_env_config_path_matches(context: Context) -> None: """Verify that --config-path set CLEVERAGENTS_CONFIG_PATH to the temp file.""" env_val = os.environ.get("CLEVERAGENTS_CONFIG_PATH") expected = str(Path(context.temp_config_file).resolve()) assert env_val is not None, ( "CLEVERAGENTS_CONFIG_PATH was not set after --config-path invocation" ) assert str(Path(env_val).resolve()) == expected, ( f"CLEVERAGENTS_CONFIG_PATH={env_val!r} does not match expected {expected!r}" ) # --------------------------------------------------------------------------- # Then steps - ctx.obj assertions # --------------------------------------------------------------------------- @then('the global options ctx obj "{key}" should be {value:d}') def step_global_opts_ctx_obj_int(context: Context, key: str, value: int) -> None: """Verify ctx.obj[key] has the expected integer value.""" obj = context.global_opts_captured_ctx_obj assert key in obj, f"ctx.obj missing key '{key}'. ctx.obj={obj}" actual = obj[key] assert actual == value, f"ctx.obj['{key}']={actual!r}, expected {value!r}" # --------------------------------------------------------------------------- # Then steps - log level assertions # --------------------------------------------------------------------------- @then('the global options log level should be "{level}"') def step_global_opts_log_level(context: Context, level: str) -> None: """Verify that configure_structlog was called with the expected log level.""" actual = context.global_opts_captured_log_level assert actual is not None, "configure_structlog was not called" assert actual.upper() == level.upper(), ( f"Expected log level {level!r}, got {actual!r}" ) # --------------------------------------------------------------------------- # Then steps - output assertions # --------------------------------------------------------------------------- @then('the global options output should contain "{text}"') def step_global_opts_output_contains(context: Context, text: str) -> None: output = context.global_opts_output assert text in output, f"Expected '{text}' in output:\n{output}"