Files
cleveragents-core/features/steps/bridge_coverage_steps.py
T
freemo c9abb45adf
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 55s
CI / integration_tests (pull_request) Successful in 3m28s
CI / unit_tests (pull_request) Successful in 10m38s
CI / docker (pull_request) Successful in 16s
CI / benchmark-regression (pull_request) Successful in 20m39s
CI / coverage (pull_request) Successful in 41m22s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 17s
CI / build (push) Successful in 22s
CI / typecheck (push) Successful in 29s
CI / security (push) Successful in 29s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m40s
CI / unit_tests (push) Successful in 11m4s
CI / docker (push) Successful in 1m5s
CI / benchmark-publish (push) Successful in 12m10s
CI / coverage (push) Successful in 1h7m46s
test(coverage): add Behave BDD scenarios for 9 under-covered modules
Added 246 new BDD scenarios across 9 feature files to improve unit test
coverage for modules that were either entirely untested or had significant
coverage gaps:

- lock_service_coverage.feature (27 scenarios): validation branches,
  TTL boundaries, re-entrant acquisition, rollback on exceptions
- plan_apply_service_coverage.feature (54 scenarios): operation labels,
  diff rendering (plain/rich/json), artifact building, validation gate,
  changeset resolution and cleanup
- plan_executor_coverage.feature (51 scenarios): step parsing, execute
  actor integration, strategize/execute guards, stub retry/recovery,
  decision tree construction
- skill_cli_coverage_r3.feature (22 scenarios): tools refresh, list/show
  JSON fallback, capability summary errors, remove confirmation
- changeset_repository_coverage.feature (39 scenarios): entry/tool repos
  validation, database error wrapping, domain conversion, SQLite store
  CRUD operations
- repositories_coverage.feature (20 scenarios): get_by_name/namespace
  errors, list_available filters, delete with ActionInUseError, plan
  update with invariants/processing_state/error_details
- sandbox_copy_on_write_coverage.feature (12 scenarios): create OSError
  wrapping, get_path state transitions, commit edge cases, rollback
  errors, cleanup with missing paths
- bridge_coverage.feature (8 scenarios): __del__ suppression, async task
  cancellation, execute_graph message type handling, stream config,
  state checkpointer
- plan_cli_coverage.feature (13 scenarios): legacy apply/list/cd paths,
  use-action with estimation/invariant actors, lifecycle-apply guards,
  status errors, error recovery display

All 246 scenarios (1105 steps) pass. Step definitions use unique prefixes
to prevent ambiguous step conflicts with existing tests.

ISSUES CLOSED: #467
2026-02-27 13:47:42 -05:00

356 lines
12 KiB
Python

"""Steps targeting uncovered lines and branches in bridge.py."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.langgraph.bridge import RxPyLangGraphBridge
from cleveragents.langgraph.state import GraphState
from cleveragents.reactive.stream_router import (
ReactiveStreamRouter,
StreamMessage,
)
# ------ helpers ------
def _make_bridge() -> tuple[RxPyLangGraphBridge, MagicMock]:
"""Create a bridge backed by a mocked stream router."""
scheduler = MagicMock()
router = ReactiveStreamRouter(scheduler=scheduler)
router.agents = {"agent": MagicMock()}
bridge = RxPyLangGraphBridge(router)
return bridge, scheduler
def _make_mock_graph(
name: str,
*,
messages: list[dict[str, Any]] | None = None,
state_dict: dict[str, Any] | None = None,
) -> MagicMock:
"""Build a mock LangGraph with a controllable execute coroutine."""
graph = MagicMock()
graph.name = name
gs = MagicMock(spec=GraphState)
gs.messages = messages if messages is not None else []
gs.to_dict = MagicMock(return_value=state_dict or {"messages": gs.messages})
graph.execute = AsyncMock(return_value=gs)
graph.get_execution_history = MagicMock(return_value=["step1"])
graph.state_manager = MagicMock()
graph.state_manager.checkpoint_dir = None
graph.nodes = {}
return graph
# ------ Scenario: __del__ suppresses AttributeError ------
@given("a bridge with its cleanup_tasks method removed")
def step_bridge_without_cleanup(context: Context) -> None:
"""Create a bridge then sabotage it so __del__ hits AttributeError."""
bridge, _ = _make_bridge()
# Delete _active_tasks so cleanup_tasks() raises AttributeError internally;
# __del__ wraps cleanup_tasks in contextlib.suppress(AttributeError).
del bridge._active_tasks
context.bridge = bridge
@when("__del__ is invoked on the crippled bridge")
def step_invoke_del(context: Context) -> None:
"""Call __del__ explicitly; the suppress(AttributeError) path should fire."""
context.del_error = None
try:
context.bridge.__del__()
except Exception as exc:
context.del_error = exc
@then("no exception should propagate from __del__")
def step_no_del_exception(context: Context) -> None:
"""Verify __del__ swallowed the AttributeError."""
assert context.del_error is None, (
f"Expected no error from __del__, got {context.del_error!r}"
)
# ------ Scenario: cleanup_tasks_async cancels running tasks ------
@given("a bridge with a long-running async task")
def step_bridge_with_long_task(context: Context) -> None:
"""Create a bridge and schedule an async task that sleeps for a long time."""
bridge, _ = _make_bridge()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def _long_sleep() -> None:
await asyncio.sleep(999)
task = loop.create_task(_long_sleep())
bridge._active_tasks.add(task)
context.bridge = bridge
context.task = task
context.loop = loop
@when("I run cleanup_tasks_async on the bridge")
def step_run_cleanup_async(context: Context) -> None:
"""Await cleanup_tasks_async so it cancels the not-done task."""
context.loop.run_until_complete(context.bridge.cleanup_tasks_async(timeout=0.1))
@then("the running task should be cancelled")
def step_assert_task_cancelled(context: Context) -> None:
"""Confirm the long-running task was cancelled."""
assert context.task.cancelled() or context.task.done(), (
f"Expected task to be cancelled or done, state={context.task._state}"
)
context.loop.close()
asyncio.set_event_loop(asyncio.new_event_loop())
@then("the bridge active tasks set should be empty")
def step_assert_active_tasks_empty(context: Context) -> None:
"""Confirm _active_tasks was cleared."""
assert len(context.bridge._active_tasks) == 0, (
f"Expected empty _active_tasks, got {len(context.bridge._active_tasks)}"
)
# ------ Scenario: Graph executor with string content (lines 201-204) ------
@given("a bridge with a mock graph that returns messages")
def step_bridge_with_graph_messages(context: Context) -> None:
"""Set up a bridge with a mock graph whose execute returns messages."""
bridge, _ = _make_bridge()
mock_graph = _make_mock_graph(
"test_exec",
messages=[{"content": "response-ok"}],
state_dict={"messages": [{"content": "response-ok"}]},
)
bridge.graphs["test_exec"] = mock_graph
context.bridge = bridge
context.mock_graph = mock_graph
@when("I run the graph executor with a string content message")
def step_exec_string_message(context: Context) -> None:
"""Push a string StreamMessage through the graph executor and await it."""
bridge = context.bridge
executor_op = bridge._create_graph_executor({"graph": "test_exec"})
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
results: list[Any] = []
errors: list[Any] = []
msg = StreamMessage(content="hello world", metadata={"origin": "test"})
import rx # type: ignore
rx.just(msg).pipe(executor_op).subscribe(
on_next=lambda x: results.append(x),
on_error=lambda e: errors.append(e),
)
# Drain event loop so the scheduled coroutine completes
pending = list(bridge._active_tasks)
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
else:
loop.run_until_complete(asyncio.sleep(0.05))
context.exec_results = results
context.exec_errors = errors
context.loop = loop
@then("the executor result should contain the graph output")
def step_assert_executor_result(context: Context) -> None:
"""Verify the executor produced a StreamMessage with expected content."""
assert len(context.exec_errors) == 0, (
f"Executor raised errors: {context.exec_errors}"
)
assert len(context.exec_results) > 0, "Executor produced no results"
result_msg = context.exec_results[0]
assert isinstance(result_msg, StreamMessage), (
f"Expected StreamMessage, got {type(result_msg).__name__}"
)
if hasattr(context, "loop") and not context.loop.is_closed():
context.loop.close()
asyncio.set_event_loop(asyncio.new_event_loop())
@then("the result metadata should include graph execution history")
def step_assert_metadata_history(context: Context) -> None:
"""Check that the result metadata carries execution_history."""
result_msg = context.exec_results[0]
assert "execution_history" in result_msg.metadata, (
f"Missing 'execution_history' in metadata keys: {list(result_msg.metadata)}"
)
assert "graph" in result_msg.metadata, (
f"Missing 'graph' in metadata keys: {list(result_msg.metadata)}"
)
@when("I run the graph executor with a dict content message")
def step_exec_dict_message(context: Context) -> None:
"""Push a dict StreamMessage through the graph executor and await it."""
bridge = context.bridge
executor_op = bridge._create_graph_executor({"graph": "test_exec"})
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
results: list[Any] = []
errors: list[Any] = []
msg = StreamMessage(
content={"messages": [{"role": "user", "content": "hi"}]},
metadata={},
)
import rx # type: ignore
rx.just(msg).pipe(executor_op).subscribe(
on_next=lambda x: results.append(x),
on_error=lambda e: errors.append(e),
)
pending = list(bridge._active_tasks)
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
else:
loop.run_until_complete(asyncio.sleep(0.05))
context.exec_results = results
context.exec_errors = errors
context.loop = loop
@when("I run the graph executor with a numeric content message")
def step_exec_numeric_message(context: Context) -> None:
"""Push a non-str/non-dict StreamMessage through the executor."""
bridge = context.bridge
executor_op = bridge._create_graph_executor({"graph": "test_exec"})
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
results: list[Any] = []
errors: list[Any] = []
msg = StreamMessage(content=42, metadata={})
import rx # type: ignore
rx.just(msg).pipe(executor_op).subscribe(
on_next=lambda x: results.append(x),
on_error=lambda e: errors.append(e),
)
pending = list(bridge._active_tasks)
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
else:
loop.run_until_complete(asyncio.sleep(0.05))
context.exec_results = results
context.exec_errors = errors
context.loop = loop
# ------ Scenario: Graph executor with empty messages ------
@given("a bridge with a mock graph that returns empty messages")
def step_bridge_with_graph_empty_messages(context: Context) -> None:
"""Set up a bridge with a mock graph whose execute returns no messages."""
bridge, _ = _make_bridge()
mock_graph = _make_mock_graph(
"test_exec",
messages=[],
state_dict={"data": "fallback-state"},
)
bridge.graphs["test_exec"] = mock_graph
context.bridge = bridge
context.mock_graph = mock_graph
@then("the executor result should be the full state dict")
def step_assert_state_dict_fallback(context: Context) -> None:
"""Verify the executor returns the full state dict when messages are empty."""
assert len(context.exec_errors) == 0, (
f"Executor raised errors: {context.exec_errors}"
)
assert len(context.exec_results) > 0, "Executor produced no results"
result_msg = context.exec_results[0]
assert isinstance(result_msg, StreamMessage), (
f"Expected StreamMessage, got {type(result_msg).__name__}"
)
# Content should be the full state dict since messages was empty
assert result_msg.content == {"data": "fallback-state"}, (
f"Expected fallback state dict, got {result_msg.content!r}"
)
if hasattr(context, "loop") and not context.loop.is_closed():
context.loop.close()
asyncio.set_event_loop(asyncio.new_event_loop())
# ------ Scenario: create_graph_stream with known graph (branch 182→184) ------
@given('a bridge that owns a registered graph named "{name}"')
def step_bridge_with_named_graph(context: Context, name: str) -> None:
"""Create a bridge and insert a mock graph under the given name."""
bridge, _ = _make_bridge()
mock_graph = _make_mock_graph(name)
bridge.graphs[name] = mock_graph
context.bridge = bridge
@when('I call create_graph_stream with "{name}"')
def step_call_create_graph_stream(context: Context, name: str) -> None:
"""Invoke create_graph_stream for the given graph name."""
context.stream_config = context.bridge.create_graph_stream(name)
@then('the returned StreamConfig name should be "{expected}"')
def step_assert_stream_config_name(context: Context, expected: str) -> None:
"""Verify the StreamConfig has the expected name."""
assert context.stream_config.name == expected, (
f"Expected name '{expected}', got '{context.stream_config.name}'"
)
# ------ Scenario: _create_state_checkpointer valid path (branch 260→258) ------
@when('I build a state checkpointer for "{name}"')
def step_build_checkpointer(context: Context, name: str) -> None:
"""Call _create_state_checkpointer with a valid graph name."""
context.checkpointer_error = None
try:
context.checkpointer_op = context.bridge._create_state_checkpointer(
{"graph": name}
)
except Exception as exc:
context.checkpointer_error = exc
context.checkpointer_op = None
@then("the checkpointer operator should be returned without error")
def step_assert_checkpointer_ok(context: Context) -> None:
"""Verify the checkpointer operator was created successfully."""
assert context.checkpointer_error is None, (
f"Expected no error, got {context.checkpointer_error!r}"
)
assert context.checkpointer_op is not None, "Expected a valid operator, got None"