Files
cleveragents-core/features/steps/cli_streaming_steps.py
T
freemo a8f7ed57cb perf(tests): reduce per-feature startup cost with shared fixtures and lazy imports
Created scripts/create_template_db.py that builds a pre-migrated SQLite
template database using Base.metadata.create_all() + alembic stamp
(~5ms for 34 tables, vs ~0.5-3s x 25 Alembic migrations per scenario).

Nox unit_tests and coverage_report sessions generate the template before
test execution and propagate CLEVERAGENTS_TEMPLATE_DB env var to all
workers.

features/environment.py before_all() installs a monkey-patch on
MigrationRunner.init_or_upgrade that copies the template for fresh
scenario temp DBs, falling through to real migrations for :memory:,
existing files, and migration-runner unit tests.

Quick wins: sleep(0.5) -> sleep(0.05) in cli_streaming wait step;
removed redundant Background re-declaration in cli_streaming.feature
scenario 7.

ISSUES CLOSED: #483
2026-03-02 02:01:27 +00:00

656 lines
23 KiB
Python

"""Step definitions for CLI streaming tests."""
import ast
import os
import re
import tempfile
import time
from pathlib import Path
import parse
from behave import given, register_type, then, when
from features.steps.provider_stub_utils import (
RecordingProviderStub,
install_provider_resolver_patch,
)
@parse.with_pattern(r"[^\"]*")
def _parse_optional_quoted(text: str) -> str:
return text
register_type(OptionalQuoted=_parse_optional_quoted)
@given("a temporary directory")
def step_create_temp_dir(context):
"""Create a temporary directory for testing."""
context.temp_dir = tempfile.mkdtemp()
context.original_dir = os.getcwd()
os.chdir(context.temp_dir)
# Give this invocation a unique database so repeated calls within the
# same scenario (e.g. Background + explicit Given) never collide.
db_path = tempfile.mktemp(suffix=".db", prefix="cleveragents_")
os.environ["CLEVERAGENTS_DATABASE_URL"] = f"sqlite:///{db_path}"
context._scenario_db_paths = getattr(context, "_scenario_db_paths", [])
context._scenario_db_paths.append(db_path)
# Reset container for clean state
from cleveragents.application.container import reset_container
reset_container()
# Set up mock AI provider for testing
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
@given('I initialize a new project named "{name}"')
def step_initialize_project(context, name):
"""Initialize a new CleverAgents project."""
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
from cleveragents.cli.commands.project import init_command
# Use the CLI command directly to properly initialize the database
init_command(name, Path.cwd())
# Store the project for later use
container = get_container()
project_service: ProjectService = container.project_service()
context.project = project_service.get_current_project()
# Verify project was initialized
assert (Path.cwd() / ".cleveragents").exists()
@given("I add a test file to the project")
def step_add_test_file(context):
"""Add a test file to the project."""
test_file = Path.cwd() / "test.py"
test_file.write_text("def hello():\n print('Hello, world!')\n")
# Ensure test_file exists
assert test_file.exists()
# Add to context using the CLI pattern
from cleveragents.cli.commands.context import add_command
add_command([str(test_file)])
@given("I add {count:d} files to the project")
def step_add_multiple_files(context, count):
"""Add multiple test files to the project."""
from cleveragents.cli.commands.context import add_command
files = []
for i in range(count):
test_file = Path.cwd() / f"test_{i}.py"
test_file.write_text(f"def function_{i}():\n pass\n")
files.append(str(test_file))
# Add all files to context using the CLI command
add_command(files)
@given("I have a context file with {count:d} python files")
def step_add_python_context_files(context, count):
"""Create and add python context files to the project."""
from cleveragents.cli.commands.context import add_command
files = []
for i in range(count):
context_file = Path.cwd() / f"context_{i}.py"
context_file.write_text("print('context file')\n")
files.append(str(context_file))
add_command(files)
@given("the AI provider is configured to fail")
def step_configure_provider_to_fail(context):
"""Configure the AI provider to fail."""
# Set environment variable to make mock provider fail
os.environ["CLEVERAGENTS_MOCK_SHOULD_FAIL"] = "true"
context.provider_should_fail = True
@given("the AI provider generates invalid code")
def step_configure_provider_invalid_code(context):
"""Configure the AI provider to generate invalid code."""
# Set environment variable to make mock provider generate invalid code
os.environ["CLEVERAGENTS_MOCK_INVALID_CODE"] = "true"
context.provider_generates_invalid = True
@given(
'actor overrides are stubbed with default "{default_actor}" and alternates "{alternates:OptionalQuoted}"'
)
def step_stub_streaming_actor_overrides(context, default_actor, alternates):
"""Install deterministic provider stubs keyed by actor selection."""
default_key = default_actor.lower()
actor_names = [
name.strip().lower() for name in alternates.split(",") if name.strip()
]
if default_key not in actor_names:
actor_names.insert(0, default_key)
overrides: dict[str, RecordingProviderStub] = {
name: RecordingProviderStub(name=name) for name in actor_names
}
install_provider_resolver_patch(
context,
override_map=overrides,
default_provider=default_key,
)
context.provider_stub_overrides = overrides
@given('actor stub "{actor}" will fail {count:d} time(s) before succeeding')
def step_stub_failures_before_success(context, actor, count):
"""Configure a stub to raise exceptions a fixed number of times."""
stub = _get_actor_stub(context, actor)
stub.should_fail = False
stub.failures_before_success = count
stub._failure_count = 0
@given('actor stub "{actor}" will always fail')
def step_stub_always_fail(context, actor):
"""Force a stub to fail every invocation."""
stub = _get_actor_stub(context, actor)
stub.should_fail = True
@when('I run tell command "{prompt}" without streaming')
def step_run_tell_without_streaming(context, prompt):
"""Run a tell command without streaming."""
from cleveragents.cli.commands.plan import tell_command
try:
tell_command(prompt=prompt, name=None)
context.command_output = "Plan created"
context.command_success = True
except Exception as e:
context.command_output = str(e)
context.command_success = False
def _strip_rich_markup(text: str) -> str:
"""Strip Rich markup codes from text for testing."""
import re
# Remove Rich color/style markup like [green], [/green], [cyan], [dim], etc.
return re.sub(r"\[/?[a-z]+\]", "", text)
def _run_streaming_tell(
context,
prompt: str,
*,
name: str | None = None,
actor: str | None = None,
) -> bool:
"""Execute the streaming tell helper and capture its output."""
import asyncio
from io import StringIO
from unittest.mock import patch
from rich.console import Console
from cleveragents.application.container import get_container
from cleveragents.application.services.plan_service import PlanService
from cleveragents.cli.commands.plan import _tell_streaming
container = get_container()
plan_service: PlanService = container.plan_service()
output = StringIO()
test_console = Console(file=output, force_terminal=False, width=120)
success = False
error_message = ""
try:
with patch("cleveragents.cli.commands.plan.console", test_console):
asyncio.run(
_tell_streaming(
context.project,
prompt,
name,
plan_service,
actor=actor,
)
)
success = True
except Exception as exc: # pragma: no cover - diagnostics surface via assertions
error_message = str(exc)
finally:
rendered_output = _strip_rich_markup(output.getvalue())
context.command_output = (
rendered_output if success else f"{rendered_output}{error_message}"
)
context.command_success = success
if not hasattr(context, "all_commands_success"):
context.all_commands_success = []
context.all_commands_success.append(success)
return success
def _get_actor_stub(context, actor: str) -> RecordingProviderStub:
overrides = getattr(context, "provider_stub_overrides", None)
assert overrides, "Provider stubs were not configured for this scenario"
stub = overrides.get(actor.lower())
assert stub is not None, f"No provider stub named '{actor}' was registered"
return stub
@when('I run tell command "{prompt}" with streaming')
def step_run_tell_with_streaming(context, prompt):
"""Run a tell command with streaming."""
_run_streaming_tell(context, prompt)
@given('actor stub "{actor}" uses config blob {config_blob}')
def step_configure_actor_stub_blob(context, actor, config_blob):
"""Attach a configuration blob to the stubbed actor."""
stub = _get_actor_stub(context, actor)
stub.config_blob = ast.literal_eval(config_blob)
@then(
'provider stub "{actor}" should receive actor context with options {options_blob} graph {graph_descriptor} initial context {initial_context}'
)
def step_assert_actor_context(
context, actor, options_blob, graph_descriptor, initial_context
):
stub = _get_actor_stub(context, actor)
ctx = stub.last_actor_context
assert ctx is not None, "Actor context was not captured for the provider stub"
expected_options = ast.literal_eval(options_blob)
expected_graph = ast.literal_eval(graph_descriptor)
expected_initial = ast.literal_eval(initial_context)
assert ctx.options == expected_options, ctx.options
assert ctx.graph_descriptor == expected_graph, ctx.graph_descriptor
assert ctx.initial_context == expected_initial, ctx.initial_context
@when('I run tell command "{prompt}" with name "{name}" and streaming')
def step_run_tell_with_name_and_streaming(context, prompt, name):
"""Run a tell command with custom name and streaming."""
context.custom_plan_name = name
_run_streaming_tell(context, prompt, name=name)
@when('I run tell command "{prompt}" with streaming and actor "{actor}"')
def step_run_tell_with_streaming_actor(context, prompt, actor):
"""Run a streaming tell command that overrides the actor."""
_run_streaming_tell(context, prompt, actor=actor)
@when('I measure time for tell command "{prompt}" without streaming')
def step_measure_time_without_streaming(context, prompt):
"""Measure execution time without streaming."""
start_time = time.time()
step_run_tell_without_streaming(context, prompt)
context.time_without_streaming = time.time() - start_time
context.without_streaming_success = context.command_success
@when('I measure time for tell command "{prompt}" with streaming')
def step_measure_time_with_streaming(context, prompt):
"""Measure execution time with streaming."""
start_time = time.time()
step_run_tell_with_streaming(context, prompt)
context.time_with_streaming = time.time() - start_time
context.with_streaming_success = context.command_success
@when("I wait for completion")
def step_wait_for_completion(context):
"""Wait for command completion."""
time.sleep(0.05)
@when("I send interrupt signal after {seconds:d} seconds")
def step_send_interrupt_signal(context, seconds):
"""Send interrupt signal (simulated)."""
# In actual tests, this would send SIGINT
context.interrupt_sent = True
@then("the command should complete successfully")
def step_command_success(context):
"""Verify command completed successfully."""
assert context.command_success, f"Command failed: {context.command_output}"
@then("the command should fail with an error message")
def step_command_should_fail(context):
"""Verify command failed as expected."""
assert not context.command_success, "Command should have failed but succeeded"
assert context.command_output, "No error message provided"
@then('the output should not contain "{text}"')
def step_output_not_contains(context, text):
"""Verify output does not contain specific text."""
assert text not in context.command_output, (
f"Did not expect '{text}' in output, got: {context.command_output}"
)
@then('the output should match the pattern "{pattern}"')
def step_output_matches_pattern(context, pattern):
"""Verify output matches regex pattern."""
assert re.search(pattern, context.command_output), (
f"Expected pattern '{pattern}' in output, got: {context.command_output}"
)
@then("each node should show progress before completion")
def step_nodes_show_progress(context):
"""Verify each node shows progress."""
nodes = [
"Loading context",
"Analyzing requirements",
"Generating plan",
"Validating",
]
for node in nodes:
assert node in context.command_output, f"Node '{node}' not found in output"
@then("timing should be sequential")
def step_timing_sequential(context):
"""Verify timing is sequential."""
# Extract all timing values
times = re.findall(r"\((\d+\.\d+)s\)", context.command_output)
assert len(times) > 0, "No timing information found"
@then('the actor resolver should resolve actors "{actors}" in order')
def step_actor_resolver_sequence(context, actors):
"""Assert the patched resolver returned actors in the expected order."""
calls = getattr(context, "provider_resolver_calls", None)
assert calls is not None, "Provider resolver patch was not installed"
expected = [name.strip().lower() for name in actors.split(",") if name.strip()]
assert expected, "Expected actor list cannot be empty"
resolved = [str(call.get("resolved", "")).lower() for call in calls]
assert resolved[-len(expected) :] == expected, (
f"Expected resolver sequence {expected}, got {resolved}"
)
@then('actor stub "{actor}" should record {count:d} streaming call(s)')
def step_assert_stub_stream_calls(context, actor, count):
"""Verify a provider stub handled the expected number of streaming requests."""
stub = _get_actor_stub(context, actor)
assert stub.stream_calls == count, (
f"Expected {count} stream calls for {actor}, got {stub.stream_calls}"
)
@then('actor stub "{actor}" should record {count:d} generate call(s)')
def step_assert_stub_generate_calls(context, actor, count):
"""Verify a provider stub handled the expected number of build requests."""
stub = _get_actor_stub(context, actor)
assert stub.generate_calls == count, (
f"Expected {count} generate calls for {actor}, got {stub.generate_calls}"
)
@then("both commands should complete successfully")
def step_both_commands_success(context):
"""Verify both timed commands succeeded."""
# Check if this is a timing comparison test (with/without streaming)
if hasattr(context, "without_streaming_success") and hasattr(
context, "with_streaming_success"
):
assert context.without_streaming_success, "Non-streaming command failed"
assert context.with_streaming_success, "Streaming command failed"
# Check if this is a sequential commands test
elif hasattr(context, "all_commands_success"):
assert len(context.all_commands_success) >= 2, (
f"Expected at least 2 commands, got {len(context.all_commands_success)}"
)
assert all(context.all_commands_success), (
f"Not all commands succeeded: {context.all_commands_success}"
)
else:
# Fallback: check if the last command succeeded
assert hasattr(context, "command_success") and context.command_success, (
"Command failed"
)
@then("both should create valid plans")
def step_both_create_plans(context):
"""Verify both commands created valid plans."""
# Check that plans were created
from cleveragents.application.container import get_container
from cleveragents.application.services.plan_service import PlanService
container = get_container()
plan_service: PlanService = container.plan_service()
plans = plan_service.list_plans(context.project)
assert len(plans) > 0, "No plans were created"
@then("the output should show which node failed")
def step_output_shows_failed_node(context):
"""Verify output shows which node failed."""
# Should contain node name and error indication
assert "Error" in context.command_output or "Failed" in context.command_output
@then("the error message should be user-friendly")
def step_error_is_user_friendly(context):
"""Verify error message is user-friendly."""
# Should not contain stack traces or technical jargon
assert "Traceback" not in context.command_output
assert "Exception" not in context.command_output
@then('a plan named "{name}" should exist')
def step_plan_exists(context, name):
"""Verify a plan with the given name exists."""
from cleveragents.application.container import get_container
from cleveragents.application.services.plan_service import PlanService
container = get_container()
plan_service: PlanService = container.plan_service()
plans = plan_service.list_plans(context.project)
plan_names = [p.name for p in plans]
# Check both exact name and normalized name (lowercase with hyphens)
normalized_name = name.lower().replace(" ", "-").replace("_", "-")
assert name in plan_names or normalized_name in plan_names, (
f"Plan '{name}' (or normalized '{normalized_name}') not found. Available: {plan_names}"
)
@then("the output should show streaming progress")
def step_output_shows_streaming(context):
"""Verify output shows streaming progress indicators."""
indicators = ["", ""]
assert any(ind in context.command_output for ind in indicators), (
"No streaming indicators found in output"
)
@then("the output should use colored text")
def step_output_uses_colors(context):
"""Verify output uses colored text (Rich formatting)."""
# Rich uses ANSI codes or unicode for formatting
# For simplicity, check for checkmarks and spinners
assert "" in context.command_output or "" in context.command_output
@then("the output should have proper indentation")
def step_output_properly_indented(context):
"""Verify output has proper indentation."""
lines = context.command_output.split("\n")
indented_lines = [line for line in lines if line.startswith(" ")]
assert len(indented_lines) > 0, "No indented lines found"
@then("checkmarks and spinners should be properly rendered")
def step_symbols_rendered(context):
"""Verify symbols are properly rendered."""
assert "" in context.command_output, "Checkmark not found"
# Spinner might not be visible in captured output
@then("each should show independent progress")
def step_each_shows_independent_progress(context):
"""Verify each command shows independent progress."""
# This is implicitly tested by the command succeeding
assert context.command_success
@then("the loading context step should take longer")
def step_loading_takes_longer(context):
"""Verify loading context takes longer with more files."""
# Extract timing for loading context - match both "Loading context" variations
match = re.search(r"Loading context[^(]*\((\d+\.\d+)s\)", context.command_output)
assert match, (
f"Could not find loading context timing in output: {context.command_output[:500]}"
)
loading_time = float(match.group(1))
# With 10 files, should take measurable time (even 0.0 is valid for fast mocks)
assert loading_time >= 0
@then("all workflow stages should complete")
def step_all_stages_complete(context):
"""Verify all workflow stages completed."""
stages = ["Loading context", "Analyzing", "Generating", "Validating"]
for stage in stages:
assert stage in context.command_output, f"Stage '{stage}' did not complete"
@then("the command should exit gracefully")
def step_command_exits_gracefully(context):
"""Verify command exited gracefully after interrupt."""
# Command should have been interrupted
assert context.interrupt_sent
@then("partial progress should be shown")
def step_partial_progress_shown(context):
"""Verify partial progress was shown."""
# At least one stage should be visible
assert "" in context.command_output or "" in context.command_output
@then("no corrupted state should remain")
def step_no_corrupted_state(context):
"""Verify no corrupted state remains."""
# Check that project state is still valid
from cleveragents.application.container import get_container
from cleveragents.application.services.project_service import ProjectService
container = get_container()
project_service: ProjectService = container.project_service()
project = project_service.get_current_project()
assert project is not None
@then("checkpoint data should be saved")
def step_checkpoint_saved(context):
"""Verify checkpoint data was saved."""
# LangGraph checkpoints are saved automatically
# This is more of a conceptual check
assert context.command_success
@then("the workflow should be resumable if interrupted")
def step_workflow_resumable(context):
"""Verify workflow is resumable."""
# With LangGraph checkpointing, workflows are resumable
assert context.command_success
@then("the validation node should show an error")
def step_validation_shows_error(context):
"""Verify validation node shows error."""
assert (
"Validating" in context.command_output
or "validation" in context.command_output.lower()
)
@then("the output should explain the validation failure")
def step_output_explains_failure(context):
"""Verify output explains the validation failure."""
# Should contain some explanation
assert (
"error" in context.command_output.lower()
or "invalid" in context.command_output.lower()
)
@then("suggestions for fixing should be provided")
def step_suggestions_provided(context):
"""Verify suggestions are provided."""
# This would require the actual implementation to provide suggestions
# For now, just check that there's some helpful output
assert len(context.command_output) > 100
@then("the output should be plain text")
def step_output_is_plain_text(context):
"""Verify output is plain text in non-TTY."""
# In non-TTY, Rich should output plain text
assert context.command_output
@then("progress information should still be visible")
def step_progress_still_visible(context):
"""Verify progress information is still visible."""
# Even in plain text, progress should be visible
nodes = ["Loading", "Analyzing", "Generating", "Validating"]
assert any(node in context.command_output for node in nodes)
@then('the description should mention "{text}"')
def step_description_mentions(context, text):
"""Verify description mentions specific text."""
# Check both stdout and stderr
combined_output = context.command_output + (
context.command_error if hasattr(context, "command_error") else ""
)
assert text in combined_output, (
f"Expected '{text}' not found in output: {combined_output}"
)
# Cleanup
def after_scenario(context, scenario):
"""Clean up after each scenario."""
import shutil
from cleveragents.application.container import reset_container
if hasattr(context, "temp_dir"):
# Change back to original directory
if hasattr(context, "original_dir"):
os.chdir(context.original_dir)
# Remove test directory
shutil.rmtree(context.temp_dir, ignore_errors=True)
# Clean up environment
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" in os.environ:
del os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"]
if "CLEVERAGENTS_MOCK_SHOULD_FAIL" in os.environ:
del os.environ["CLEVERAGENTS_MOCK_SHOULD_FAIL"]
if "CLEVERAGENTS_MOCK_INVALID_CODE" in os.environ:
del os.environ["CLEVERAGENTS_MOCK_INVALID_CODE"]
# Reset container
reset_container()