Files
temp/features/steps/skill_cli_coverage_r3_steps.py
T
freemo c9abb45adf 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

604 lines
21 KiB
Python

# pyright: reportRedeclaration=false
"""Step definitions for skill_cli_coverage_r3.feature.
Targets remaining uncovered branches in
``cleveragents.cli.commands.skill``: tools --refresh, list/show
capability-summary fallback, refresh agent_skills discovery, refresh
errors, long description truncation, tools ValueError, and inline tool
capability display.
All step text uses the ``r3skill-`` prefix to avoid collisions with
existing step definitions.
"""
from __future__ import annotations
import json
import tempfile
from dataclasses import dataclass, field
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.commands.skill import (
_get_skill_service,
_reset_skill_service,
)
from cleveragents.cli.commands.skill import app as skill_app
from cleveragents.domain.models.core.skill import (
Skill,
SkillAgentSource,
SkillInclude,
SkillInlineTool,
SkillMcpSource,
)
from cleveragents.domain.models.core.tool import ToolCapability, ToolSource
# ── helpers ─────────────────────────────────────────────────
def _make_skill(
name: str,
description: str = "test skill",
tool_refs: list[str] | None = None,
includes: list[SkillInclude] | None = None,
mcp_servers: list[SkillMcpSource] | None = None,
agent_skills: list[SkillAgentSource] | None = None,
anonymous_tools: list[SkillInlineTool] | None = None,
) -> Skill:
"""Create a Skill domain object with optional components."""
return Skill(
name=name,
description=description,
tool_refs=tool_refs or [],
includes=includes or [],
mcp_servers=mcp_servers or [],
agent_skills=agent_skills or [],
anonymous_tools=anonymous_tools or [],
)
def _register_skill(context: Context, skill: Skill) -> None:
"""Register a skill in the service with timestamps."""
from datetime import datetime
now = datetime.now()
context.r3_service._skills[skill.name] = skill
context.r3_service._created_at[skill.name] = now
context.r3_service._updated_at[skill.name] = now
def _write_yaml(content: str) -> str:
"""Write YAML content to a temp file and return the path."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, prefix="r3skill_"
) as tmp:
tmp.write(content)
tmp.flush()
return tmp.name
# ── Stub classes for mocking ────────────────────────────────
@dataclass(slots=True)
class StubResolvedValue:
"""Stub for ConfigService.resolve return value."""
value: Any = None
@dataclass(slots=True)
class StubDiscoveredAgentSkill:
"""Stub for DiscoveredAgentSkill."""
name: str = "stub-agent"
description: str = "stub"
path: str = "/tmp/stub"
@dataclass(slots=True)
class StubDiscoveryResult:
"""Stub for DiscoveryResult."""
discovered: list[Any] = field(default_factory=list)
conflicts: list[Any] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
# ── Background ──────────────────────────────────────────────
@given("r3skill- a fresh skill CLI service")
def step_r3_background(context: Context) -> None:
"""Reset module-level singleton and prepare runner/service."""
_reset_skill_service()
context.r3_runner = CliRunner()
context.r3_service = _get_skill_service()
context.r3_result = None
context.r3_temp_paths = [] # list[str]
context.r3_patches = [] # list[Any]
context.r3_second_temp = None # str | None
# ── Given steps ─────────────────────────────────────────────
@given('r3skill- a registered skill "{name}" with tool_refs')
def step_r3_register_skill_with_tool_refs(context: Context, name: str) -> None:
"""Directly insert a skill with builtin tool_refs into the service."""
skill = _make_skill(name=name, tool_refs=["builtin/read-file"])
_register_skill(context, skill)
@given('r3skill- a registered skill "{name}" with a description longer than 50 chars')
def step_r3_register_skill_long_desc(context: Context, name: str) -> None:
"""Register a skill with a description exceeding 50 characters."""
long_desc = "A" * 60
skill = _make_skill(name=name, description=long_desc, tool_refs=["builtin/echo"])
_register_skill(context, skill)
@given('r3skill- a registered skill "{name}" with inline tools having capabilities')
def step_r3_register_inline_caps(context: Context, name: str) -> None:
"""Register a skill with inline tools that have writes and checkpointable."""
inline = SkillInlineTool(
description="Inline with caps",
source=ToolSource.CUSTOM,
code="return True",
timeout=300,
capability=ToolCapability(
read_only=False,
writes=True,
checkpointable=True,
),
)
skill = _make_skill(name=name, anonymous_tools=[inline])
_register_skill(context, skill)
@given('r3skill- timestamps for "{name}" are removed')
def step_r3_remove_timestamps(context: Context, name: str) -> None:
"""Remove created_at and updated_at for a skill."""
context.r3_service._created_at.pop(name, None)
context.r3_service._updated_at.pop(name, None)
@given(
"r3skill- agent_skills_paths config resolves to a valid path with discovered skills"
)
def step_r3_agent_paths_valid(context: Context) -> None:
"""Set up mocks so refresh/tools --refresh discovers agent skills."""
stub_resolved = StubResolvedValue(value="/tmp/skills")
stub_discovery = StubDiscoveryResult(
discovered=[StubDiscoveredAgentSkill()],
errors=[],
)
p1 = patch(
"cleveragents.application.services.config_service.ConfigService",
)
mock_config_cls = p1.start()
mock_instance = MagicMock()
mock_instance.resolve.return_value = stub_resolved
mock_config_cls.return_value = mock_instance
context.r3_patches.append(p1)
p2 = patch(
"cleveragents.skills.discovery.parse_agent_skills_paths",
return_value=["/tmp/skills"],
)
p2.start()
context.r3_patches.append(p2)
p3 = patch(
"cleveragents.skills.discovery.discover_agent_skills",
return_value=stub_discovery,
)
p3.start()
context.r3_patches.append(p3)
@given("r3skill- agent_skills_paths config resolves to empty string")
def step_r3_agent_paths_empty(context: Context) -> None:
"""Set up mock so agent_skills_paths resolves to empty."""
stub_resolved = StubResolvedValue(value="")
p1 = patch(
"cleveragents.application.services.config_service.ConfigService",
)
mock_config_cls = p1.start()
mock_instance = MagicMock()
mock_instance.resolve.return_value = stub_resolved
mock_config_cls.return_value = mock_instance
context.r3_patches.append(p1)
@given("r3skill- agent_skills_paths config resolves with discovery errors")
def step_r3_agent_paths_errors(context: Context) -> None:
"""Set up mocks so discovery returns errors."""
stub_resolved = StubResolvedValue(value="/tmp/skills")
stub_discovery = StubDiscoveryResult(
discovered=[],
errors=["Error scanning /tmp/skills: permission denied"],
)
p1 = patch(
"cleveragents.application.services.config_service.ConfigService",
)
mock_config_cls = p1.start()
mock_instance = MagicMock()
mock_instance.resolve.return_value = stub_resolved
mock_config_cls.return_value = mock_instance
context.r3_patches.append(p1)
p2 = patch(
"cleveragents.skills.discovery.parse_agent_skills_paths",
return_value=["/tmp/skills"],
)
p2.start()
context.r3_patches.append(p2)
p3 = patch(
"cleveragents.skills.discovery.discover_agent_skills",
return_value=stub_discovery,
)
p3.start()
context.r3_patches.append(p3)
@given('r3skill- resolve_tools will raise ValueError for "{name}"')
def step_r3_resolve_tools_valueerror(context: Context, name: str) -> None:
"""Patch resolve_tools to raise ValueError for a specific skill."""
original_resolve = context.r3_service.resolve_tools
def patched_resolve(skill_name: str) -> Any:
"""Raise ValueError for targeted skill only."""
if skill_name == name:
raise ValueError(f"Cycle detected in {name}")
return original_resolve(skill_name)
p = patch.object(context.r3_service, "resolve_tools", side_effect=patched_resolve)
p.start()
context.r3_patches.append(p)
@given("r3skill- compute_capability_summary will raise ValueError")
def step_r3_cap_summary_valueerror(context: Context) -> None:
"""Patch compute_capability_summary to raise ValueError."""
p = patch.object(
context.r3_service,
"compute_capability_summary",
side_effect=ValueError("cap summary error"),
)
p.start()
context.r3_patches.append(p)
@given("r3skill- compute_capability_summary will raise KeyError")
def step_r3_cap_summary_keyerror(context: Context) -> None:
"""Patch compute_capability_summary to raise KeyError."""
p = patch.object(
context.r3_service,
"compute_capability_summary",
side_effect=KeyError("missing"),
)
p.start()
context.r3_patches.append(p)
@given("r3skill- list_skills will raise RuntimeError")
def step_r3_list_skills_runtime_error(context: Context) -> None:
"""Patch list_skills to raise RuntimeError."""
p = patch.object(
context.r3_service,
"list_skills",
side_effect=RuntimeError("unexpected error"),
)
p.start()
context.r3_patches.append(p)
@given('r3skill- a temp YAML for "{name}" that includes "{included}"')
def step_r3_temp_yaml_with_include(context: Context, name: str, included: str) -> None:
"""Create a temp YAML config that includes another skill."""
yaml_content = f"""\
name: {name}
description: Skill with include
tools:
- name: builtin/write-file
includes:
- name: {included}
"""
path = _write_yaml(yaml_content)
context.r3_temp_paths.append(path)
@given('r3skill- a temp YAML for "{name}" with tool_refs and MCP')
def step_r3_temp_yaml_tools_and_mcp(context: Context, name: str) -> None:
"""Create a temp YAML config with tool_refs and MCP servers."""
yaml_content = f"""\
name: {name}
description: Original skill with MCP
tools:
- name: builtin/read-file
mcp_servers:
- name: mcp-server-a
transport: stdio
tool_filter:
include:
- tool-x
"""
path = _write_yaml(yaml_content)
context.r3_temp_paths.append(path)
@given('r3skill- the skill "{name}" is pre-registered via add')
def step_r3_pre_register_via_add(context: Context, name: str) -> None:
"""Register a skill via the CLI add command."""
config_path = context.r3_temp_paths[-1]
result = context.r3_runner.invoke(
skill_app, ["add", "--config", config_path, "--format", "json"]
)
assert result.exit_code == 0, f"Pre-registration failed: {result.output}"
@given('r3skill- a second temp YAML for "{name}" with different includes and MCP')
def step_r3_second_yaml_diff_includes_mcp(context: Context, name: str) -> None:
"""Create a second YAML config with different includes and MCP."""
yaml_content = f"""\
name: {name}
description: Updated skill with changed includes and MCP
tools:
- name: builtin/write-file
includes:
- name: local/new-include
mcp_servers:
- name: mcp-server-b
transport: stdio
tool_filter:
include:
- tool-y
"""
path = _write_yaml(yaml_content)
context.r3_second_temp = path
# ── When steps ──────────────────────────────────────────────
@when('r3skill- I invoke tools "{name}" with --refresh')
def step_r3_invoke_tools_refresh(context: Context, name: str) -> None:
"""Invoke skill tools with --refresh flag."""
context.r3_result = context.r3_runner.invoke(
skill_app, ["tools", name, "--refresh"]
)
_stop_patches(context)
@when('r3skill- I invoke tools "{name}" in rich format')
def step_r3_invoke_tools_rich(context: Context, name: str) -> None:
"""Invoke skill tools in default rich format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["tools", name])
_stop_patches(context)
@when('r3skill- I invoke list in format "{fmt}"')
def step_r3_invoke_list_fmt(context: Context, fmt: str) -> None:
"""Invoke skill list with a specified format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["list", "--format", fmt])
_stop_patches(context)
@when("r3skill- I invoke list in rich format")
def step_r3_invoke_list_rich(context: Context) -> None:
"""Invoke skill list in default rich format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["list"])
_stop_patches(context)
@when('r3skill- I invoke show "{name}" in format "{fmt}"')
def step_r3_invoke_show_fmt(context: Context, name: str, fmt: str) -> None:
"""Invoke skill show with a specified format."""
context.r3_result = context.r3_runner.invoke(
skill_app, ["show", name, "--format", fmt]
)
_stop_patches(context)
@when('r3skill- I invoke show "{name}" in rich format')
def step_r3_invoke_show_rich(context: Context, name: str) -> None:
"""Invoke skill show in default rich format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["show", name])
_stop_patches(context)
@when('r3skill- I invoke refresh "{name}" in rich format')
def step_r3_invoke_refresh_rich(context: Context, name: str) -> None:
"""Invoke skill refresh for a single skill in rich format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["refresh", name])
_stop_patches(context)
@when('r3skill- I invoke refresh "{name}" in format "{fmt}"')
def step_r3_invoke_refresh_fmt(context: Context, name: str, fmt: str) -> None:
"""Invoke skill refresh with a specified format."""
context.r3_result = context.r3_runner.invoke(
skill_app, ["refresh", name, "--format", fmt]
)
_stop_patches(context)
@when("r3skill- I invoke refresh --all in rich format")
def step_r3_invoke_refresh_all_rich(context: Context) -> None:
"""Invoke skill refresh --all in rich format."""
context.r3_result = context.r3_runner.invoke(skill_app, ["refresh", "--all"])
_stop_patches(context)
@when('r3skill- I invoke refresh --all in format "{fmt}"')
def step_r3_invoke_refresh_all_fmt(context: Context, fmt: str) -> None:
"""Invoke skill refresh --all with a specified format."""
context.r3_result = context.r3_runner.invoke(
skill_app, ["refresh", "--all", "--format", fmt]
)
_stop_patches(context)
@when("r3skill- I invoke add with the temp config in rich format")
def step_r3_invoke_add_rich(context: Context) -> None:
"""Invoke skill add with the most recent temp config."""
config_path = context.r3_temp_paths[-1]
context.r3_result = context.r3_runner.invoke(
skill_app, ["add", "--config", config_path]
)
_stop_patches(context)
@when("r3skill- I invoke add with the second config and --update in rich format")
def step_r3_invoke_add_update_rich(context: Context) -> None:
"""Invoke skill add --update with the second temp config."""
assert context.r3_second_temp is not None
context.r3_result = context.r3_runner.invoke(
skill_app, ["add", "--config", context.r3_second_temp, "--update"]
)
_stop_patches(context)
@when('r3skill- I invoke remove "{name}" without --yes and confirm')
def step_r3_invoke_remove_confirm(context: Context, name: str) -> None:
"""Invoke skill remove without --yes and type y to confirm."""
context.r3_result = context.r3_runner.invoke(
skill_app, ["remove", name], input="y\n"
)
_stop_patches(context)
# ── Then steps ──────────────────────────────────────────────
@then("r3skill- the CLI exit code should be 0")
def step_r3_exit_code_0(context: Context) -> None:
"""Assert CLI exited successfully."""
assert context.r3_result is not None
assert context.r3_result.exit_code == 0, (
f"Expected exit_code=0, got {context.r3_result.exit_code}\n"
f"Output: {context.r3_result.output}"
)
@then("r3skill- the CLI exit code should not be 0")
def step_r3_exit_code_not_0(context: Context) -> None:
"""Assert CLI exited with an error."""
assert context.r3_result is not None
assert context.r3_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.r3_result.exit_code}\n"
f"Output: {context.r3_result.output}"
)
@then('r3skill- the output should contain "{text}"')
def step_r3_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the specified text."""
assert context.r3_result is not None
assert text in context.r3_result.output, (
f"Expected output to contain '{text}'\n"
f"Actual output: {context.r3_result.output}"
)
@then('r3skill- the output should not contain "{text}"')
def step_r3_output_not_contains(context: Context, text: str) -> None:
"""Assert the CLI output does not contain the specified text."""
assert context.r3_result is not None
assert text not in context.r3_result.output, (
f"Expected output NOT to contain '{text}'\n"
f"Actual output: {context.r3_result.output}"
)
@then("r3skill- the output should be valid JSON")
def step_r3_output_valid_json(context: Context) -> None:
"""Assert the CLI output parses as valid JSON."""
assert context.r3_result is not None
try:
context.r3_parsed_json = json.loads(context.r3_result.output)
except json.JSONDecodeError as e:
raise AssertionError(
f"Output is not valid JSON: {e}\nOutput: {context.r3_result.output}"
) from e
@then('r3skill- the JSON output should have key "{key}"')
def step_r3_json_has_key(context: Context, key: str) -> None:
"""Assert a key is present in the parsed JSON dict."""
data: Any = context.r3_parsed_json
if isinstance(data, list):
assert len(data) > 0, "JSON list is empty"
assert key in data[0], (
f"Key '{key}' not found in first element: {list(data[0].keys())}"
)
else:
assert key in data, f"Key '{key}' not found in: {list(data.keys())}"
@then('r3skill- the JSON list first item should have key "{key}"')
def step_r3_json_list_first_has_key(context: Context, key: str) -> None:
"""Assert the first item in the JSON list has the given key."""
data: Any = context.r3_parsed_json
assert isinstance(data, list), f"Expected JSON list, got {type(data).__name__}"
assert len(data) > 0, "JSON list is empty"
assert key in data[0], (
f"Key '{key}' not found in first element: {list(data[0].keys())}"
)
@then('r3skill- the JSON list first item "{key}" should be null')
def step_r3_json_list_first_key_null(context: Context, key: str) -> None:
"""Assert the first item's key is null/None."""
data: Any = context.r3_parsed_json
assert isinstance(data, list), f"Expected JSON list, got {type(data).__name__}"
assert len(data) > 0, "JSON list is empty"
assert key in data[0], (
f"Key '{key}' not found in first element: {list(data[0].keys())}"
)
assert data[0][key] is None, f"Expected '{key}' to be null, got {data[0][key]}"
@then('r3skill- the JSON output key "{key}" should be null')
def step_r3_json_key_null(context: Context, key: str) -> None:
"""Assert a top-level JSON key is null/None."""
data: Any = context.r3_parsed_json
assert isinstance(data, dict), f"Expected JSON dict, got {type(data).__name__}"
assert key in data, f"Key '{key}' not found in: {list(data.keys())}"
assert data[key] is None, f"Expected '{key}' to be null, got {data[key]}"
@then("r3skill- the long description in the output should be truncated")
def step_r3_long_desc_truncated(context: Context) -> None:
"""Assert the output contains a truncated description (with ... or ellipsis)."""
assert context.r3_result is not None
output = context.r3_result.output
# The code adds "..." but Rich may render it as unicode ellipsis
has_dots = "..." in output or "\u2026" in output
assert has_dots, (
f"Expected truncated description (... or \u2026) in output\n"
f"Actual output: {output}"
)
# ── Cleanup helper ──────────────────────────────────────────
def _stop_patches(context: Context) -> None:
"""Stop all active patches after a When step."""
import contextlib
for p in getattr(context, "r3_patches", []):
with contextlib.suppress(RuntimeError):
p.stop()
context.r3_patches = []