Files
temp/features/steps/tdd_init_yes_no_input_steps.py
hurui200320 1878998b7a refactor(testing): rename tdd_bug/tdd_bug_N tags to tdd_issue/tdd_issue_N
Rename the TDD tag system from tdd_bug/tdd_bug_<N> to tdd_issue/tdd_issue_<N>
across the entire codebase. The tdd_expected_fail tag is unchanged.

The TDD expected-failure workflow is not limited to bug fixes — it applies
equally to any issue type (features, tasks, refactors). The _bug suffix was
misleading and narrowed the perceived scope. The new _issue suffix accurately
reflects that the TDD tagging system applies to any Forgejo issue.

Changes span 92 files:
- features/environment.py: validate_tdd_tags(), should_invert_result(), and
  apply_tdd_inversion() updated — regex, variables, error messages
- robot/tdd_expected_fail_listener.py: _validate_tdd_tags(), _should_invert_result(),
  start_test(), end_test() updated consistently
- 33 Behave .feature files: all @tdd_bug/@tdd_bug_<N> tags renamed
- 29 Robot .robot files: all tdd_bug/tdd_bug_<N> tags renamed
- 3 Robot fixture files renamed (tdd_bug_alone, tdd_missing_tdd_bug,
  tdd_expected_fail_missing_bug_n) with content and references updated
- Tag validation tests and helpers updated (function names, command dispatch
  keys, output strings, fixture references)
- CONTRIBUTING.md: section renamed from 'TDD Bug Test Tags' to
  'TDD Issue Test Tags', all tag references and examples updated
- noxfile.py: comment references updated
- Step definition files, mock helpers, and benchmark files: docstring
  references updated

ISSUES CLOSED: #965
2026-03-27 05:58:35 +00:00

227 lines
8.9 KiB
Python

"""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}"
)