"""Step definitions for TDD Issue #783 — init --yes should not require user input. These steps exercise the *real* ``agents init --yes`` code path while simulating a TTY environment by replacing the migration runner's ``_default_prompt_for_migration`` with a function that declines migration (simulating a user on a real terminal providing no input). The ``@tdd_expected_fail`` tag on the scenarios inverts the result: these tests **pass** CI while the bug is present and will **fail** once the bug is fixed (signalling that the tag should be removed). Root cause ~~~~~~~~~~ ``init_command()`` in ``project.py`` receives the ``--yes`` flag but does not forward it to the migration runner. The migration runner's ``_default_prompt_for_migration()`` calls ``typer.confirm()`` when a TTY is detected, blocking the command for user input even though ``--yes`` was specified. In a non-TTY environment (like CliRunner), the prompt is silently skipped — the bug only surfaces in real terminal sessions. We replace the prompt function to reproduce the TTY code path. Mock strategy ~~~~~~~~~~~~~ CliRunner replaces ``sys.stdin`` during ``invoke()``, so patching ``sys.stdin.isatty()`` alone is ineffective. Instead we: 1. Patch ``sys.stdin`` on the real ``sys`` module (documents intent and is the correct target per the project's established pattern in ``features/steps/migration_runner_steps.py``). 2. Patch ``MigrationRunner._default_prompt_for_migration`` with a replacement that returns ``False`` — simulating a TTY prompt where the user declines migration. This ensures the prompt code path is exercised deterministically regardless of CliRunner's stdin replacement. 3. Set ``CLEVERAGENTS_DATABASE_URL`` to a path inside our tmp directory whose filename does **not** match the ``before_scenario`` template-DB prefixes (``cleveragents_*``, ``test_*``, ``db.*``). This forces the migration runner's ``init_or_upgrade`` through the real Alembic path instead of the test fast-path that copies a pre-migrated template. With bug #783 present, ``require_confirmation=True`` is hardcoded in ``UnitOfWork._ensure_database_initialized()``, so the prompt fires and returns ``False``, causing ``MigrationNotApprovedError``. After the fix, ``--yes`` should bypass the prompt entirely. """ from __future__ import annotations import os import shutil import sys import tempfile from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from typer.testing import CliRunner from cleveragents.application.container import reset_container from cleveragents.cli.commands.project import app as project_app from cleveragents.infrastructure.database.migration_runner import MigrationRunner runner = CliRunner() def _tty_prompt_declines(_message: str) -> bool: """Simulate a TTY migration prompt that declines (no user input). Replaces ``_default_prompt_for_migration`` to reproduce the TTY code path without depending on ``sys.stdin.isatty()``, which CliRunner overrides during ``invoke()``. """ return False # --------------------------------------------------------------------------- # Given steps # --------------------------------------------------------------------------- @given("a fresh environment for tdd-init-yes-no-input") def step_fresh_environment(context: Context) -> None: """Set up a fresh temporary environment with no existing database. Creates a temporary directory to serve as the working directory so that ``agents init`` creates a fresh ``.cleveragents`` directory. Disables auto-apply environment variables so the real prompt behaviour is exercised. """ context.tdd_init_tmpdir = tempfile.mkdtemp(prefix="tdd_init_yes_") # Save env vars that would bypass the prompt or redirect the # database, and remove them. ``CLEVERAGENTS_DATABASE_URL`` and # ``CLEVERAGENTS_TEST_DATABASE_URL`` are set by ``before_scenario`` # to pre-migrated scenario databases — removing them forces the # container to derive a fresh DB path from ``CLEVERAGENTS_HOME``. context.tdd_init_saved_env = { k: os.environ.pop(k, None) for k in ( "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "CI", "BEHAVE_TESTING", "CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL", ) } # Register cleanup to restore env vars and remove tmpdir even if # subsequent steps are never reached (e.g. hook errors). saved_env = context.tdd_init_saved_env tmpdir = context.tdd_init_tmpdir def _cleanup() -> None: for key, val in saved_env.items(): if val is not None: os.environ[key] = val else: os.environ.pop(key, None) shutil.rmtree(tmpdir, ignore_errors=True) reset_container() context.add_cleanup(_cleanup) # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when("I invoke agents init --yes with no stdin") def step_invoke_init_yes_no_stdin(context: Context) -> None: """Invoke ``agents init --yes`` simulating a TTY with no real input. Uses ``CliRunner`` with ``input=""`` to provide no input. Patches ``sys.stdin`` on the real ``sys`` module for intent documentation and replaces ``MigrationRunner._default_prompt_for_migration`` with a function that returns ``False`` (simulating a TTY prompt where the user declines). See module docstring for the full mock strategy. """ # Create a mock stdin that reports as a TTY but has no data mock_stdin = MagicMock(spec=sys.stdin) mock_stdin.isatty.return_value = True # Reset the global container to ensure a fresh UoW is created # using our env var overrides (no stale CLEVERAGENTS_DATABASE_URL). reset_container() # Use a DB path whose filename (``init_yes_test.sqlite``) does # NOT start with the template-fast-path prefixes # (``cleveragents_*``, ``test_*``, ``db.*``) so the real Alembic # migration path runs instead of copying a pre-migrated template. # Do NOT pre-create directories — ``init_command`` expects a # fresh path without an existing ``.cleveragents`` directory. tdd_db_path = os.path.join( context.tdd_init_tmpdir, ".cleveragents", "init_yes_test.sqlite" ) tdd_db_url = f"sqlite:///{tdd_db_path}" with patch.dict( os.environ, { "CLEVERAGENTS_HOME": context.tdd_init_tmpdir, "CLEVERAGENTS_DATABASE_URL": tdd_db_url, }, ): # Remove auto-apply env vars inside the patched environment for key in ( "CLEVERAGENTS_AUTO_APPLY_MIGRATIONS", "CI", "BEHAVE_TESTING", ): os.environ.pop(key, None) # Patch sys.stdin on the real sys module (correct target per # project convention) and replace the prompt function to # simulate a TTY decline. See module docstring for why # both patches are needed. with ( patch.object(sys, "stdin", mock_stdin), patch.object( MigrationRunner, "_default_prompt_for_migration", staticmethod(_tty_prompt_declines), ), ): context.tdd_init_result = runner.invoke( project_app, ["init", "--yes", "--path", context.tdd_init_tmpdir], input="", ) # --------------------------------------------------------------------------- # Then steps # --------------------------------------------------------------------------- @then("the tdd-init-yes-no-input command should exit successfully") def step_exit_success(context: Context) -> None: """Assert the init command exited with code 0.""" result = context.tdd_init_result assert result is not None, "init --yes was not invoked" actual = result.exit_code assert actual == 0, f"Expected exit code 0, got {actual}.\nOutput:\n{result.output}" @then('the tdd-init-yes-no-input output should not contain "{text}"') def step_output_not_contains(context: Context, text: str) -> None: """Assert the given text does NOT appear in the command output.""" result = context.tdd_init_result assert result is not None, "init --yes was not invoked" output = result.output assert text.lower() not in output.lower(), ( f"Did NOT expect '{text}' in output but found it:\n{output}" ) @then('the tdd-init-yes-no-input output should contain "{text}"') def step_output_contains(context: Context, text: str) -> None: """Assert the given text appears in the command output.""" result = context.tdd_init_result assert result is not None, "init --yes was not invoked" output = result.output assert text.lower() in output.lower(), ( f"Expected '{text}' in output but did not find it:\n{output}" )