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
This commit is contained in:
2026-02-27 18:37:20 +00:00
parent a074b4846f
commit c9abb45adf
18 changed files with 8311 additions and 569 deletions
+48
View File
@@ -0,0 +1,48 @@
Feature: RxPyLangGraphBridge uncovered lines and branches
As a developer
I want targeted tests for missed lines and branches in bridge.py
So that line-rate and branch-rate approach 1.0
# Targets: lines 68, 201-204 and branches 3938, 6768, 182184, 260258
Scenario: __del__ suppresses AttributeError when cleanup_tasks is unavailable
Given a bridge with its cleanup_tasks method removed
When __del__ is invoked on the crippled bridge
Then no exception should propagate from __del__
Scenario: cleanup_tasks_async cancels tasks that are still running
Given a bridge with a long-running async task
When I run cleanup_tasks_async on the bridge
Then the running task should be cancelled
And the bridge active tasks set should be empty
Scenario: Graph executor processes a string message through execute_graph
Given a bridge with a mock graph that returns messages
When I run the graph executor with a string content message
Then the executor result should contain the graph output
And the result metadata should include graph execution history
Scenario: Graph executor processes a dict message through execute_graph
Given a bridge with a mock graph that returns messages
When I run the graph executor with a dict content message
Then the executor result should contain the graph output
Scenario: Graph executor processes a non-str non-dict message through execute_graph
Given a bridge with a mock graph that returns messages
When I run the graph executor with a numeric content message
Then the executor result should contain the graph output
Scenario: Graph executor falls back to state dict when messages list is empty
Given a bridge with a mock graph that returns empty messages
When I run the graph executor with a string content message
Then the executor result should be the full state dict
Scenario: create_graph_stream returns StreamConfig for a known graph
Given a bridge that owns a registered graph named "demo"
When I call create_graph_stream with "demo"
Then the returned StreamConfig name should be "graph_demo"
Scenario: _create_state_checkpointer succeeds for a valid graph name
Given a bridge that owns a registered graph named "ckpt_graph"
When I build a state checkpointer for "ckpt_graph"
Then the checkpointer operator should be returned without error
@@ -0,0 +1,241 @@
@unit
Feature: ChangeSet repository edge cases and error coverage
As a developer maintaining changeset persistence
I want all error paths, validation, and edge cases covered
So that the changeset repository is reliable and well-tested
# ------ ChangeSetEntryRepository validation ------
Scenario: ChangeSetEntryRepository save_entry rejects empty changeset_id
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-save an entry with empty changeset_id
Then a coverage ValueError should be raised with message "changeset_id must not be empty"
Scenario: ChangeSetEntryRepository save_entry rejects non-ChangeEntry
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-save a non-ChangeEntry object
Then a coverage TypeError should be raised with message "entry must be a ChangeEntry instance"
Scenario: ChangeSetEntryRepository get_entries_for_changeset rejects empty changeset_id
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-get entries for empty changeset_id
Then a coverage ValueError should be raised with message "changeset_id must not be empty"
Scenario: ChangeSetEntryRepository get_entries_for_plan rejects empty plan_id
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-get entries for empty plan_id
Then a coverage ValueError should be raised with message "plan_id must not be empty"
Scenario: ChangeSetEntryRepository delete_for_changeset rejects empty changeset_id
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-delete entries for empty changeset_id
Then a coverage ValueError should be raised with message "changeset_id must not be empty"
Scenario: ChangeSetEntryRepository delete_for_plan rejects empty plan_id
Given I have a coverage ChangeSetEntryRepository
When I try to coverage-delete entries for empty plan_id
Then a coverage ValueError should be raised with message "plan_id must not be empty"
# ------ ChangeSetEntryRepository database errors ------
Scenario: ChangeSetEntryRepository save_entry raises DatabaseError on OperationalError
Given I have a coverage ChangeSetEntryRepository with a broken session
When I try to coverage-save an entry that triggers an OperationalError
Then a coverage DatabaseError should be raised with message "Failed to save changeset entry"
Scenario: ChangeSetEntryRepository get_entries_for_changeset raises DatabaseError
Given I have a coverage ChangeSetEntryRepository with a broken query session
When I try to coverage-get entries for changeset "cs-broken"
Then a coverage DatabaseError should be raised with message "Failed to get entries"
Scenario: ChangeSetEntryRepository get_entries_for_plan raises DatabaseError
Given I have a coverage ChangeSetEntryRepository with a broken query session
When I try to coverage-get entries for plan "plan-broken"
Then a coverage DatabaseError should be raised with message "Failed to get entries for plan"
Scenario: ChangeSetEntryRepository delete_for_changeset raises DatabaseError
Given I have a coverage ChangeSetEntryRepository with a broken delete session
When I try to coverage-delete entries for changeset "cs-broken"
Then a coverage DatabaseError should be raised with message "Failed to delete entries"
Scenario: ChangeSetEntryRepository delete_for_plan raises DatabaseError
Given I have a coverage ChangeSetEntryRepository with a broken delete session
When I try to coverage-delete entries for plan "plan-broken"
Then a coverage DatabaseError should be raised with message "Failed to delete entries"
# ------ ChangeSetEntryRepository _to_domain edge cases ------
Scenario: ChangeSetEntryRepository _to_domain with null hashes and modes
Given I have a coverage ChangeSetEntryModel row with null hashes and modes
When I convert the coverage row to a domain ChangeEntry
Then the domain entry before_hash should be None
And the domain entry after_hash should be None
And the domain entry before_mode should be None
And the domain entry after_mode should be None
Scenario: ChangeSetEntryRepository _to_domain with populated hashes and modes
Given I have a coverage ChangeSetEntryModel row with hashes and modes
When I convert the coverage row to a domain ChangeEntry
Then the domain entry before_hash should be "abc123"
And the domain entry after_hash should be "def456"
And the domain entry before_mode should be 33188
And the domain entry after_mode should be 33261
Scenario: ChangeSetEntryRepository _to_domain with empty string hashes
Given I have a coverage ChangeSetEntryModel row with empty string hashes
When I convert the coverage row to a domain ChangeEntry
Then the domain entry before_hash should be None
And the domain entry after_hash should be None
# ------ ToolInvocationRepository validation ------
Scenario: ToolInvocationRepository save_invocation rejects non-ToolInvocation
Given I have a coverage ToolInvocationRepository
When I try to coverage-save a non-ToolInvocation object
Then a coverage TypeError should be raised with message "invocation must be a ToolInvocation instance"
Scenario: ToolInvocationRepository get_invocations_for_plan rejects empty plan_id
Given I have a coverage ToolInvocationRepository
When I try to coverage-get invocations for empty plan_id
Then a coverage ValueError should be raised with message "plan_id must not be empty"
Scenario: ToolInvocationRepository delete_for_plan rejects empty plan_id
Given I have a coverage ToolInvocationRepository
When I try to coverage-delete invocations for empty plan_id
Then a coverage ValueError should be raised with message "plan_id must not be empty"
# ------ ToolInvocationRepository database errors ------
Scenario: ToolInvocationRepository save_invocation raises DatabaseError on OperationalError
Given I have a coverage ToolInvocationRepository with a broken session
When I try to coverage-save an invocation that triggers an OperationalError
Then a coverage DatabaseError should be raised with message "Failed to save tool invocation"
Scenario: ToolInvocationRepository get_invocations_for_plan raises DatabaseError
Given I have a coverage ToolInvocationRepository with a broken query session
When I try to coverage-get invocations for plan "plan-broken"
Then a coverage DatabaseError should be raised with message "Failed to get invocations"
Scenario: ToolInvocationRepository delete_for_plan raises DatabaseError
Given I have a coverage ToolInvocationRepository with a broken delete session
When I try to coverage-delete invocations for plan "plan-broken"
Then a coverage DatabaseError should be raised with message "Failed to delete invocations"
# ------ ToolInvocationRepository save_invocation optional fields ------
Scenario: ToolInvocationRepository saves invocation with all optional fields populated
Given I have a coverage ToolInvocationRepository with real session
When I coverage-save an invocation with result, completed_at, and provider_metadata
Then the coverage-saved invocation should round-trip with all fields intact
Scenario: ToolInvocationRepository saves invocation with all optional fields as None
Given I have a coverage ToolInvocationRepository with real session
When I coverage-save an invocation with no result, no completed_at, and no provider_metadata
Then the coverage-saved invocation should round-trip with None optional fields
Scenario: ToolInvocationRepository saves invocation with changeset_id as None
Given I have a coverage ToolInvocationRepository with real session
When I coverage-save an invocation without changeset_id
Then the coverage-saved invocation for plan should be retrievable
# ------ ToolInvocationRepository _to_domain edge cases ------
Scenario: ToolInvocationRepository _to_domain with null JSON fields
Given I have a coverage ToolInvocationModel row with null JSON fields
When I convert the coverage row to a domain ToolInvocation
Then the domain invocation arguments should be an empty dict
And the domain invocation result should be None
And the domain invocation change_ids should be an empty list
And the domain invocation resource_refs should be an empty list
And the domain invocation provider_metadata should be None
Scenario: ToolInvocationRepository _to_domain with populated JSON fields
Given I have a coverage ToolInvocationModel row with populated JSON fields
When I convert the coverage row to a domain ToolInvocation
Then the domain invocation arguments should have key "path"
And the domain invocation result should have key "status"
And the domain invocation change_ids should have 2 entries
And the domain invocation resource_refs should have 1 entry
And the domain invocation provider_metadata should have key "model"
Scenario: ToolInvocationRepository _to_domain with null duration_ms and sequence_number
Given I have a coverage ToolInvocationModel row with null numeric fields
When I convert the coverage row to a domain ToolInvocation
Then the domain invocation duration_ms should be 0.0
And the domain invocation sequence_number should be 0
Scenario: ToolInvocationRepository _to_domain with null completed_at
Given I have a coverage ToolInvocationModel row with null completed_at
When I convert the coverage row to a domain ToolInvocation
Then the domain invocation completed_at should be None
Scenario: ToolInvocationRepository _to_domain with populated completed_at
Given I have a coverage ToolInvocationModel row with populated completed_at
When I convert the coverage row to a domain ToolInvocation
Then the domain invocation completed_at should be a datetime
# ------ SqliteChangeSetStore edge cases ------
Scenario: SqliteChangeSetStore get returns None for empty changeset_id
Given I have a coverage sqlite changeset store
When I coverage-get a changeset with empty string
Then the coverage-get result should be None
Scenario: SqliteChangeSetStore get returns empty SpecChangeSet for known ID without entries
Given I have a coverage sqlite changeset store
When I coverage-start a changeset for plan "plan-known"
And I coverage-get the started changeset
Then the coverage-get result should be a SpecChangeSet with 0 entries
And the coverage-get result plan_id should be "plan-known"
Scenario: SqliteChangeSetStore get returns None for unknown ID not in plan_map
Given I have a coverage sqlite changeset store
When I coverage-get a changeset with id "totally-unknown-id"
Then the coverage-get result should be None
Scenario: SqliteChangeSetStore get returns SpecChangeSet with entries
Given I have a coverage sqlite changeset store
When I coverage-start a changeset for plan "plan-with-entries"
And I coverage-record a create entry in the started changeset
And I coverage-get the started changeset
Then the coverage-get result should be a SpecChangeSet with 1 entries
And the coverage-get result plan_id should be "plan-with-entries"
Scenario: SqliteChangeSetStore record rejects empty changeset_id
Given I have a coverage sqlite changeset store
When I try to coverage-record an entry with empty changeset_id
Then a coverage ValueError should be raised with message "changeset_id must not be empty"
Scenario: SqliteChangeSetStore get_for_plan returns empty list for empty plan_id
Given I have a coverage sqlite changeset store
When I coverage-get_for_plan with empty plan_id
Then the coverage-get_for_plan result should be an empty list
Scenario: SqliteChangeSetStore get_for_plan returns empty list when no entries exist
Given I have a coverage sqlite changeset store
When I coverage-get_for_plan with plan_id "nonexistent-plan"
Then the coverage-get_for_plan result should be an empty list
Scenario: SqliteChangeSetStore summarize returns empty dict for empty changeset_id
Given I have a coverage sqlite changeset store
When I coverage-summarize changeset with empty string
Then the coverage-summarize result should be an empty dict
Scenario: SqliteChangeSetStore summarize returns summary for valid changeset
Given I have a coverage sqlite changeset store
When I coverage-start a changeset for plan "plan-sum"
And I coverage-record a create entry in the started changeset
And I coverage-summarize the started changeset
Then the coverage-summarize result total should be 1
Scenario: SqliteChangeSetStore delete_for_plan rejects empty plan_id
Given I have a coverage sqlite changeset store
When I try to coverage-delete_for_plan with empty plan_id
Then a coverage ValueError should be raised with message "plan_id must not be empty"
Scenario: SqliteChangeSetStore delete_for_plan returns count
Given I have a coverage sqlite changeset store
When I coverage-start a changeset for plan "plan-del-cov"
And I coverage-record a create entry in the started changeset
And I coverage-delete_for_plan "plan-del-cov"
Then the coverage-delete count should be 1
+175
View File
@@ -0,0 +1,175 @@
Feature: LockService coverage uncovered branches and edge cases
As a developer
I want full coverage of every branch in lock_service.py
So that no edge case is left untested
Background:
Given I have a fresh lock service with in-memory database
# ------------------------------------------------------------------
# Validation: release method
# ------------------------------------------------------------------
Scenario: Release rejects empty resource_type
When I try to release with an empty resource_type
Then a lock_service validation error should be raised with message containing "resource_type must not be empty"
Scenario: Release rejects invalid resource_type
When I try to release with resource_type "widget" owner "o" resource_id "r1"
Then a lock_service validation error should be raised with message containing "resource_type must be one of"
Scenario: Release rejects empty resource_id
When I try to release with an empty resource_id
Then a lock_service validation error should be raised with message containing "resource_id must not be empty"
# ------------------------------------------------------------------
# Validation: renew method
# ------------------------------------------------------------------
Scenario: Renew rejects empty resource_type
When I try to renew with an empty resource_type
Then a lock_service validation error should be raised with message containing "resource_type must not be empty"
Scenario: Renew rejects invalid resource_type
When I try to renew with resource_type "widget" owner "o" resource_id "r1"
Then a lock_service validation error should be raised with message containing "resource_type must be one of"
Scenario: Renew rejects empty resource_id
When I try to renew with an empty resource_id
Then a lock_service validation error should be raised with message containing "resource_id must not be empty"
Scenario: Renew rejects TTL below minimum
When I try to renew with TTL 2 owner "o" resource_type "plan" resource_id "r1"
Then a lock_service validation error should be raised with message containing "ttl_seconds must be >="
Scenario: Renew rejects TTL above maximum
When I try to renew with TTL 7200 owner "o" resource_type "plan" resource_id "r1"
Then a lock_service validation error should be raised with message containing "ttl_seconds must be <="
# ------------------------------------------------------------------
# Validation: is_locked method
# ------------------------------------------------------------------
Scenario: is_locked rejects empty resource_type
When I try to check is_locked with an empty resource_type
Then a lock_service validation error should be raised with message containing "resource_type must not be empty"
Scenario: is_locked rejects invalid resource_type
When I try to check is_locked with resource_type "widget" resource_id "r1"
Then a lock_service validation error should be raised with message containing "resource_type must be one of"
Scenario: is_locked rejects empty resource_id
When I try to check is_locked with an empty resource_id
Then a lock_service validation error should be raised with message containing "resource_id must not be empty"
# ------------------------------------------------------------------
# TTL boundary values on acquire
# ------------------------------------------------------------------
Scenario: Acquire with explicit minimum TTL succeeds
When I acquire a lock with TTL 5 owner "owner-min" resource_type "plan" resource_id "p-min"
Then the lock_service result should be true
Scenario: Acquire with explicit maximum TTL succeeds
When I acquire a lock with TTL 3600 owner "owner-max" resource_type "plan" resource_id "p-max"
Then the lock_service result should be true
Scenario: Acquire with custom valid TTL succeeds
When I acquire a lock with TTL 120 owner "owner-cust" resource_type "project" resource_id "proj-cust"
Then the lock_service result should be true
# ------------------------------------------------------------------
# Re-entrant acquisition on project resource
# ------------------------------------------------------------------
Scenario: Same owner re-acquires a project lock
Given owner "owner-a" already holds a project lock on "proj-re"
When I acquire a lock with TTL 300 owner "owner-a" resource_type "project" resource_id "proj-re"
Then the lock_service result should be true
# ------------------------------------------------------------------
# Release by wrong owner returns False
# ------------------------------------------------------------------
Scenario: Release returns false when owner does not match
Given owner "owner-a" already holds a plan lock on "plan-wrong"
When I try to release with resource_type "plan" owner "wrong-owner" resource_id "plan-wrong"
Then the lock_service result should be false
# ------------------------------------------------------------------
# is_locked on expired lock returns False
# ------------------------------------------------------------------
Scenario: is_locked returns false for an expired lock
Given an expired lock exists for owner "old" on plan "plan-exp"
When I check is_locked for resource_type "plan" resource_id "plan-exp"
Then the lock_service result should be false
# ------------------------------------------------------------------
# Zero-count paths (no log emitted)
# ------------------------------------------------------------------
Scenario: release_all_for_owner returns zero when owner has no locks
When I release all locks for owner_id "ghost"
Then the lock_service integer result should be 0
Scenario: cleanup_expired returns zero when no expired locks exist
When I run cleanup of expired locks
Then the lock_service integer result should be 0
Scenario: count_stale_locks returns zero when no stale locks exist
When I count stale locks via the service
Then the lock_service integer result should be 0
# ------------------------------------------------------------------
# Exception rollback paths (mocked session)
# ------------------------------------------------------------------
Scenario: Acquire rolls back on unexpected exception
Given a lock service whose session raises an unexpected error on execute
When I try to acquire and expect a RuntimeError
Then a RuntimeError should have been raised
And the session should have been rolled back
Scenario: Release rolls back on unexpected exception
Given a lock service whose session raises an unexpected error on execute
When I try to release and expect a RuntimeError
Then a RuntimeError should have been raised
And the session should have been rolled back
Scenario: Renew rolls back on unexpected exception
Given a lock service whose session raises an unexpected error on execute
When I try to renew and expect a RuntimeError
Then a RuntimeError should have been raised
And the session should have been rolled back
Scenario: release_all_for_owner rolls back on unexpected exception
Given a lock service whose session raises an unexpected error on execute
When I try to release_all_for_owner and expect a RuntimeError
Then a RuntimeError should have been raised
And the session should have been rolled back
Scenario: cleanup_expired rolls back on unexpected exception
Given a lock service whose session raises an unexpected error on execute
When I try to cleanup_expired and expect a RuntimeError
Then a RuntimeError should have been raised
And the session should have been rolled back
# ------------------------------------------------------------------
# Acquire conflict rollback path
# ------------------------------------------------------------------
Scenario: Acquire rolls back the session on LockConflictError
Given a lock service with a real database and owner "holder" holds plan "plan-conf"
When I try to acquire plan "plan-conf" as "intruder" and capture the conflict error
Then a LockConflictError should have been raised
And the conflict owner should be "holder"
# ------------------------------------------------------------------
# Renew LockExpiredError rollback path
# ------------------------------------------------------------------
Scenario: Renew rolls back the session on LockExpiredError
Given a lock service with a real database and an expired lock for "exp-owner" on plan "plan-exp-r"
When I try to renew plan "plan-exp-r" as "exp-owner" and capture the expired error
Then a LockExpiredError should have been raised
@@ -0,0 +1,377 @@
Feature: PlanApplyService full coverage
As a developer
I want comprehensive tests for PlanApplyService
So that all code paths, edge cases, and error branches are covered
# ======================================================================
# Helper function coverage: _operation_label
# ======================================================================
Scenario: operation label returns correct label for "create"
When pas_cov I call _operation_label with "create"
Then pas_cov the label should be "new file"
Scenario: operation label returns correct label for "modify"
When pas_cov I call _operation_label with "modify"
Then pas_cov the label should be "modified"
Scenario: operation label returns correct label for "delete"
When pas_cov I call _operation_label with "delete"
Then pas_cov the label should be "deleted"
Scenario: operation label returns correct label for "rename"
When pas_cov I call _operation_label with "rename"
Then pas_cov the label should be "renamed"
Scenario: operation label returns the raw op for unknown operations
When pas_cov I call _operation_label with "unknown_op"
Then pas_cov the label should be "unknown_op"
# ======================================================================
# Helper function coverage: _render_diff_plain
# ======================================================================
Scenario: render diff plain with empty changeset
Given pas_cov a SpecChangeSet with no entries
When pas_cov I render diff plain
Then pas_cov the plain diff should be "No changes in changeset."
Scenario: render diff plain with entries having both hashes
Given pas_cov a SpecChangeSet with a modify entry having both hashes
When pas_cov I render diff plain
Then pas_cov the plain diff should contain "ChangeSet:"
And pas_cov the plain diff should contain "Total changes: 1"
And pas_cov the plain diff should contain "--- a/src/app.py"
And pas_cov the plain diff should contain "+++ b/src/app.py"
And pas_cov the plain diff should contain "@@ modified @@"
And pas_cov the plain diff should contain "- hash: abcdef012345..."
And pas_cov the plain diff should contain "+ hash: 123456abcdef..."
Scenario: render diff plain with create entry missing before_hash
Given pas_cov a SpecChangeSet with a create entry having only after_hash
When pas_cov I render diff plain
Then pas_cov the plain diff should contain "@@ new file @@"
And pas_cov the plain diff should contain "+ hash:"
And pas_cov the plain diff should not contain "- hash:"
Scenario: render diff plain with delete entry missing after_hash
Given pas_cov a SpecChangeSet with a delete entry having only before_hash
When pas_cov I render diff plain
Then pas_cov the plain diff should contain "@@ deleted @@"
And pas_cov the plain diff should contain "- hash:"
And pas_cov the plain diff should not contain "+ hash:"
# ======================================================================
# Helper function coverage: _render_diff_rich
# ======================================================================
Scenario: render diff rich with empty changeset
Given pas_cov a SpecChangeSet with no entries
When pas_cov I render diff rich
Then pas_cov the rich diff should contain "[yellow]No changes in changeset.[/yellow]"
Scenario: render diff rich with known operation colors
Given pas_cov a SpecChangeSet with a modify entry having both hashes
When pas_cov I render diff rich
Then pas_cov the rich diff should contain "[yellow]modified[/yellow]"
And pas_cov the rich diff should contain "[bold]ChangeSet:[/bold]"
And pas_cov the rich diff should contain "[bold]Total changes:[/bold] 1"
And pas_cov the rich diff should contain "[red]- abcdef012345...[/red]"
And pas_cov the rich diff should contain "[green]+ 123456abcdef...[/green]"
Scenario: render diff rich with unknown operation uses white color
Given pas_cov a SpecChangeSet with an entry having operation "rename"
When pas_cov I render diff rich
Then pas_cov the rich diff should contain "[cyan]renamed[/cyan]"
Scenario: render diff rich with create entry hides before_hash
Given pas_cov a SpecChangeSet with a create entry having only after_hash
When pas_cov I render diff rich
Then pas_cov the rich diff should contain "[green]"
And pas_cov the rich diff should not contain "[red]- "
# ======================================================================
# Helper function coverage: _render_diff_json
# ======================================================================
Scenario: render diff json returns correct structure
Given pas_cov a SpecChangeSet with a modify entry having both hashes
When pas_cov I render diff json
Then pas_cov the json diff should have key "changeset_id"
And pas_cov the json diff should have key "plan_id"
And pas_cov the json diff should have key "total_changes" with value 1
And pas_cov the json diff should have key "summary"
And pas_cov the json diff should have key "entries"
And pas_cov the json diff entries should have length 1
Scenario: render diff json entry contains all expected fields
Given pas_cov a SpecChangeSet with a modify entry having both hashes
When pas_cov I render diff json
Then pas_cov the json diff first entry should have "path" equal to "src/app.py"
And pas_cov the json diff first entry should have "operation" equal to "modify"
And pas_cov the json diff first entry should have key "timestamp"
And pas_cov the json diff first entry should have key "resource_id"
And pas_cov the json diff first entry should have key "tool_name"
# ======================================================================
# Helper function coverage: _build_artifacts_dict
# ======================================================================
Scenario: build artifacts dict with changeset present
Given pas_cov a mock plan with changeset_id "CS001"
And pas_cov a SpecChangeSet with a modify entry having both hashes
When pas_cov I build artifacts dict
Then pas_cov the artifacts dict should have key "plan_id"
And pas_cov the artifacts dict should have key "changeset_summary"
And pas_cov the artifacts dict files_changed list should have length 1
Scenario: build artifacts dict with no changeset
Given pas_cov a mock plan with changeset_id "CS001"
When pas_cov I build artifacts dict with no changeset
Then pas_cov the artifacts dict changeset_summary should be null
And pas_cov the artifacts dict files_changed list should have length 0
Scenario: build artifacts dict with validation summary
Given pas_cov a mock plan with changeset_id "CS001" and validation summary
When pas_cov I build artifacts dict with no changeset
Then pas_cov the artifacts dict should have key "validation_summary"
Scenario: build artifacts dict with apply summary in error_details
Given pas_cov a mock plan with apply summary in error_details
When pas_cov I build artifacts dict with no changeset
Then pas_cov the artifacts dict should have key "apply_summary"
And pas_cov the artifacts dict apply_summary files_changed should be "5"
# ======================================================================
# Service initialization
# ======================================================================
Scenario: PlanApplyService raises ValidationError when lifecycle is None
When pas_cov I create PlanApplyService with None lifecycle service
Then pas_cov a ValidationError should be raised
Scenario: PlanApplyService initializes with valid lifecycle and no store
Given pas_cov a mock lifecycle service
When pas_cov I create PlanApplyService with valid lifecycle and no store
Then pas_cov the service should be created successfully
Scenario: PlanApplyService initializes with valid lifecycle and a store
Given pas_cov a mock lifecycle service
And pas_cov a mock changeset store
When pas_cov I create PlanApplyService with valid lifecycle and store
Then pas_cov the service should be created successfully
# ======================================================================
# diff method
# ======================================================================
Scenario: diff raises PlanError when plan has no changeset
Given pas_cov a service with a plan that has no changeset_id
When pas_cov I call diff on the service
Then pas_cov a PlanError should be raised with message containing "no ChangeSet"
Scenario: diff returns rich format by default
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call diff with format "rich"
Then pas_cov the diff result should contain "[bold]ChangeSet:[/bold]"
Scenario: diff returns plain format
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call diff with format "plain"
Then pas_cov the diff result should contain "ChangeSet:"
And pas_cov the diff result should not contain "[bold]"
Scenario: diff returns json format
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call diff with format "json"
Then pas_cov the diff result should be valid JSON
Scenario: diff returns yaml format
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call diff with format "yaml"
Then pas_cov the diff result should contain "changeset_id:"
# ======================================================================
# artifacts method
# ======================================================================
Scenario: artifacts returns json format output
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call artifacts with format "json"
Then pas_cov the artifacts result should contain "plan_id"
Scenario: artifacts returns plain format output
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call artifacts with format "plain"
Then pas_cov the artifacts result should be a non-empty string
Scenario: artifacts falls back to json for rich format
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call artifacts with format "rich"
Then pas_cov the artifacts result should contain "plan_id"
# ======================================================================
# persist_apply_summary
# ======================================================================
Scenario: persist apply summary stores metadata on plan with no prior error_details
Given pas_cov a service with a plan that has no error_details
When pas_cov I persist apply summary with 3 files and 2 validations
Then pas_cov the plan error_details should contain "apply_files_changed" with value "3"
And pas_cov the plan error_details should contain "apply_validations_run" with value "2"
And pas_cov the plan error_details should contain key "apply_completed_at"
And pas_cov the lifecycle _commit_plan should have been called
Scenario: persist apply summary preserves existing error_details
Given pas_cov a service with a plan that has existing error_details
When pas_cov I persist apply summary with 1 files and 0 validations
Then pas_cov the plan error_details should contain "existing_key" with value "existing_value"
And pas_cov the plan error_details should contain "apply_files_changed" with value "1"
# ======================================================================
# handle_merge_failure
# ======================================================================
Scenario: handle merge failure stores conflict details with no prior error_details
Given pas_cov a service with a plan that has no error_details
When pas_cov I handle merge failure with conflict "file.py has conflicts"
Then pas_cov the lifecycle fail_apply should have been called
And pas_cov the returned plan should reflect errored state
Scenario: handle merge failure preserves existing error_details
Given pas_cov a service with a plan that has existing error_details
When pas_cov I handle merge failure with conflict "merge.txt conflict"
Then pas_cov the committed plan error_details should contain "existing_key"
And pas_cov the committed plan error_details should contain "merge_conflict"
And pas_cov the committed plan error_details should contain "sandbox_rollback"
# ======================================================================
# guard_empty_changeset
# ======================================================================
Scenario: guard passes when changeset has entries
Given pas_cov a service with a plan that has a changeset with entries
When pas_cov I call guard_empty_changeset
Then pas_cov the guard should return true
Scenario: guard passes when changeset is empty but allow_empty is true
Given pas_cov a service with a plan that has an empty changeset
When pas_cov I call guard_empty_changeset with allow_empty true
Then pas_cov the guard should return true
Scenario: guard raises PlanError when changeset is empty
Given pas_cov a service with a plan that has an empty changeset
When pas_cov I call guard_empty_changeset
Then pas_cov a PlanError should be raised with message containing "empty ChangeSet"
Scenario: guard raises PlanError when changeset is None
Given pas_cov a service with a plan that has no changeset_id
When pas_cov I call guard_empty_changeset
Then pas_cov a PlanError should be raised with message containing "empty ChangeSet"
# ======================================================================
# _extract_validation_counts
# ======================================================================
Scenario: extract validation counts from None returns zeros
When pas_cov I extract validation counts from None
Then pas_cov the counts should be 0 passed 0 failed 0 total
Scenario: extract validation counts from full summary
When pas_cov I extract validation counts from full summary 3 passed 1 failed 6 total
Then pas_cov the counts should be 3 passed 1 failed 6 total
Scenario: extract validation counts computes total when missing
When pas_cov I extract validation counts from partial summary 2 passed 1 failed
Then pas_cov the counts should be 2 passed 1 failed 3 total
# ======================================================================
# _resolve_changeset
# ======================================================================
Scenario: resolve changeset returns None when plan has no changeset_id
Given pas_cov a service with a plan that has no changeset_id
When pas_cov I call _resolve_changeset
Then pas_cov the resolved changeset should be None
Scenario: resolve changeset returns from store when available
Given pas_cov a service with a plan that has changeset_id and store returns a changeset
When pas_cov I call _resolve_changeset
Then pas_cov the resolved changeset should have entries
Scenario: resolve changeset falls back to empty stub when store returns None
Given pas_cov a service with a plan that has changeset_id but store returns None
When pas_cov I call _resolve_changeset
Then pas_cov the resolved changeset should have no entries
And pas_cov the resolved changeset should have the plan changeset_id
Scenario: resolve changeset falls back to empty stub when no store configured
Given pas_cov a service with a plan that has changeset_id and no store
When pas_cov I call _resolve_changeset
Then pas_cov the resolved changeset should have no entries
# ======================================================================
# cleanup_changeset
# ======================================================================
Scenario: cleanup changeset raises ValidationError for empty plan_id
Given pas_cov a service with a mock lifecycle
When pas_cov I call cleanup_changeset with empty plan_id
Then pas_cov a ValidationError should be raised
Scenario: cleanup changeset delegates to store delete_for_plan
Given pas_cov a service with a store that has delete_for_plan returning 5
When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001"
Then pas_cov cleanup should return 5
Scenario: cleanup changeset returns 0 when store is None
Given pas_cov a service with no changeset store
When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001"
Then pas_cov cleanup should return 0
Scenario: cleanup changeset returns 0 when store has no delete_for_plan
Given pas_cov a service with a store that lacks delete_for_plan
When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001"
Then pas_cov cleanup should return 0
# ======================================================================
# apply_with_validation_gate: constrain_apply raises exception branch
# ======================================================================
Scenario: apply with validation gate handles constrain_apply exception
Given pas_cov a service where constrain_apply raises PlanError
And pas_cov the plan has failed validations
When pas_cov I call apply_with_validation_gate
Then pas_cov the result outcome should be "constrained"
And pas_cov the result message should contain "Apply refused"
# ======================================================================
# apply_with_validation_gate: complete_apply raises exception branch
# ======================================================================
Scenario: apply with validation gate handles complete_apply exception
Given pas_cov a service where complete_apply raises PlanError
And pas_cov the plan has passing validations and entries
When pas_cov I call apply_with_validation_gate
Then pas_cov the result outcome should be "applied"
And pas_cov the result message should contain "applied successfully"
# ======================================================================
# ApplyOutcome and ApplyResult model
# ======================================================================
Scenario: ApplyOutcome enum has all expected values
Then pas_cov ApplyOutcome should have value "applied"
And pas_cov ApplyOutcome should have value "constrained"
And pas_cov ApplyOutcome should have value "already_applied"
And pas_cov ApplyOutcome should have value "blocked_empty"
Scenario: ApplyResult model validates and strips whitespace
When pas_cov I create an ApplyResult with whitespace in message
Then pas_cov the ApplyResult message should be stripped
Scenario: OP_COLORS dict has expected keys
Then pas_cov OP_COLORS should map "create" to "green"
And pas_cov OP_COLORS should map "modify" to "yellow"
And pas_cov OP_COLORS should map "delete" to "red"
And pas_cov OP_COLORS should map "rename" to "cyan"
+123
View File
@@ -0,0 +1,123 @@
Feature: Plan CLI uncovered lines and branches coverage
As a developer
I want to exercise the uncovered lines and branches in plan.py
So that code coverage gaps at lines 80, 818, 827-828, 999, 1017-1035,
1314-1315, 1391-1392, 1573-1578, 1594-1596, 1700-1701, 1739 are closed
# ===================================================================
# Legacy apply command Progress block and PlanError handler
# ===================================================================
Scenario: Legacy apply succeeds through Progress spinner block
Given a plan-cov CLI runner
And a mocked legacy container for plan-cov apply that succeeds
When I invoke plan-cov legacy apply with --yes
Then the plan-cov command should exit normally
And the plan-cov output should contain "Successfully applied"
Scenario: Legacy apply raises PlanError
Given a plan-cov CLI runner
And a mocked legacy container for plan-cov apply that raises PlanError
When I invoke plan-cov legacy apply with --yes
Then the plan-cov command should abort
And the plan-cov output should contain "Apply Error"
# ===================================================================
# Legacy list command — plan.current=False branch
# ===================================================================
Scenario: Legacy list shows plans with current=False
Given a plan-cov CLI runner
And a mocked legacy container for plan-cov list with non-current plans
When I invoke plan-cov legacy list
Then the plan-cov command should exit normally
And the plan-cov output should contain "Plans"
# ===================================================================
# Legacy cd command — full body coverage
# ===================================================================
Scenario: Legacy cd switches to an existing plan
Given a plan-cov CLI runner
And a mocked legacy container for plan-cov cd that succeeds
When I invoke plan-cov legacy cd "my-plan"
Then the plan-cov command should exit normally
And the plan-cov output should contain "Switched to plan"
Scenario: Legacy cd raises CleverAgentsError
Given a plan-cov CLI runner
And a mocked legacy container for plan-cov cd that raises CleverAgentsError
When I invoke plan-cov legacy cd "bad-plan"
Then the plan-cov command should abort
And the plan-cov output should contain "Error"
# ===================================================================
# use command — invariant_actor and invariants
# ===================================================================
Scenario: Use action with estimation-actor and invariant-actor overrides
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov use action
When I invoke plan-cov use with estimation-actor "openai/gpt-4" and invariant-actor "openai/gpt-4"
Then the plan-cov command should exit normally
And the plan-cov created plan should have estimation_actor set
Scenario: Use action with --invariant flags builds plan invariants
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov use action
When I invoke plan-cov use with invariant "No new warnings"
Then the plan-cov command should exit normally
# ===================================================================
# lifecycle-apply — no plan_id auto-discovery branches
# ===================================================================
Scenario: lifecycle-apply without plan_id when no plans are ready
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov that returns no execute-complete plans
When I invoke plan-cov lifecycle-apply without plan_id
Then the plan-cov command should abort
And the plan-cov output should contain "No plans ready for apply"
Scenario: lifecycle-apply without plan_id when multiple plans are ready
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov that returns multiple execute-complete plans
When I invoke plan-cov lifecycle-apply without plan_id
Then the plan-cov command should abort
And the plan-cov output should contain "Multiple plans ready"
Scenario: lifecycle-apply rejects a read-only plan
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov that returns a read-only plan
When I invoke plan-cov lifecycle-apply with plan_id "01ARZ3NDEKTSV4RRFFQ69G5FAA"
Then the plan-cov command should abort
And the plan-cov output should contain "read-only"
# ===================================================================
# status command — CleverAgentsError handler
# ===================================================================
Scenario: status command raises CleverAgentsError
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov status that raises CleverAgentsError
When I invoke plan-cov status "01ARZ3NDEKTSV4RRFFQ69G5FAA"
Then the plan-cov command should abort
And the plan-cov output should contain "Error"
# ===================================================================
# errors command — no error_category branch
# ===================================================================
Scenario: errors command on plan with no error recovery data
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov errors with no error_category
When I invoke plan-cov errors "01ARZ3NDEKTSV4RRFFQ69G5FAA" with format "json"
Then the plan-cov command should exit normally
And the plan-cov output should contain "plan_id"
Scenario: errors command on plan with empty error_details in rich format
Given a plan-cov CLI runner
And a mocked lifecycle service for plan-cov errors with empty details
When I invoke plan-cov errors "01ARZ3NDEKTSV4RRFFQ69G5FAA"
Then the plan-cov command should exit normally
And the plan-cov output should contain "No error recovery records"
+357 -121
View File
@@ -1,158 +1,394 @@
Feature: PlanExecutor coverage for uncovered lines
Feature: PlanExecutor comprehensive coverage
As a developer
I want to test uncovered paths in plan_executor.py
So that lines 144, 233-235, 273, 483, 490 are covered
I want thorough test coverage of plan_executor.py
So that all branches and edge cases are exercised
# --- StrategizeStubActor._parse_steps empty input (line 144) ---
# ------------------------------------------------------------------
# StrategizeStubActor._parse_steps parsing branches
# ------------------------------------------------------------------
Scenario: Parse steps returns default when input is empty string
Given a StrategizeStubActor instance for coverage
When I call _parse_steps with an empty string
Then the parsed steps should be the default fallback
Scenario: Parse steps with dash bullet prefix
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "- First item\n- Second item"
Then the cov2 parsed steps count should be "2"
And the cov2 parsed step at index "0" should be "First item"
And the cov2 parsed step at index "1" should be "Second item"
Scenario: Parse steps returns default when input is whitespace only
Given a StrategizeStubActor instance for coverage
When I call _parse_steps with whitespace only
Then the parsed steps should be the default fallback
Scenario: Parse steps with asterisk bullet prefix
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "* Alpha\n* Beta"
Then the cov2 parsed steps count should be "2"
And the cov2 parsed step at index "0" should be "Alpha"
And the cov2 parsed step at index "1" should be "Beta"
# --- StrategizeStubActor.execute happy path ---
Scenario: Parse steps with bullet character prefix
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "\u2022 Uno\n\u2022 Dos"
Then the cov2 parsed steps count should be "2"
And the cov2 parsed step at index "0" should be "Uno"
And the cov2 parsed step at index "1" should be "Dos"
Scenario: Strategize stub actor produces decisions from definition of done
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a valid plan and definition of done
Then the strategize result should contain decisions matching the steps
Scenario: Parse steps with dot-numbered items
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "1. Do alpha\n2. Do beta\n10. Do gamma"
Then the cov2 parsed steps count should be "3"
And the cov2 parsed step at index "0" should be "Do alpha"
And the cov2 parsed step at index "1" should be "Do beta"
And the cov2 parsed step at index "2" should be "Do gamma"
Scenario: Strategize stub actor handles None definition of done
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a None definition of done
Then the strategize result should contain the default decision
Scenario: Parse steps with paren-numbered items
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "1) Task one\n2) Task two"
Then the cov2 parsed steps count should be "2"
And the cov2 parsed step at index "0" should be "Task one"
And the cov2 parsed step at index "1" should be "Task two"
Scenario: Strategize stub actor rejects empty plan_id
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with an empty plan_id
Then a plan executor ValidationError should be raised containing "plan_id"
Scenario: Parse steps skips blank lines in input
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "Line A\n\n\nLine B"
Then the cov2 parsed steps count should be "2"
And the cov2 parsed step at index "0" should be "Line A"
And the cov2 parsed step at index "1" should be "Line B"
Scenario: Strategize stub actor processes invariants
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with invariants
Then the strategize result should contain invariant records
Scenario: Parse steps with prefix that leaves empty content returns default
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "- "
Then the cov2 parsed steps should be the default objective
Scenario: Strategize stub actor invokes stream callback
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a stream callback
Then the stream callback should have been called with strategize events
Scenario: Parse steps with empty string returns default
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from an empty string
Then the cov2 parsed steps should be the default objective
# --- ExecuteStubActor happy path ---
Scenario: Parse steps with whitespace-only returns default
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from " \n\t "
Then the cov2 parsed steps should be the default objective
Scenario: Execute stub actor produces a changeset
Given an ExecuteStubActor instance for coverage
When I execute the execute stub with decisions
Then the execute result should contain a changeset id
Scenario: Parse steps with plain text lines
Given a fresh StrategizeStubActor for cov2
When I cov2 parse steps from "Just a sentence"
Then the cov2 parsed steps count should be "1"
And the cov2 parsed step at index "0" should be "Just a sentence"
Scenario: Execute stub actor rejects empty plan_id
Given an ExecuteStubActor instance for coverage
When I execute the execute stub with an empty plan_id
Then a plan executor ValidationError should be raised containing "plan_id"
# ------------------------------------------------------------------
# StrategizeStubActor.execute - validation and edge cases
# ------------------------------------------------------------------
# --- PlanExecutor construction ---
Scenario: Strategize stub rejects empty plan_id
Given a fresh StrategizeStubActor for cov2
When I cov2 execute strategize with an empty plan_id
Then a cov2 ValidationError should be raised with message containing "plan_id"
Scenario: PlanExecutor rejects None lifecycle service
When I construct a PlanExecutor with None lifecycle service
Then a plan executor ValidationError should be raised containing "lifecycle_service"
Scenario: Strategize stub with None definition of done produces default decision
Given a fresh StrategizeStubActor for cov2
When I cov2 execute strategize with plan_id "PLAN01" and no definition
Then the cov2 strategize result should have "1" decisions
And the cov2 first decision text should be "Complete the plan objectives"
Scenario: PlanExecutor has_runtime is False without execution context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor has_runtime should be False
Scenario: Strategize stub with multi-line definition produces correct decisions
Given a fresh StrategizeStubActor for cov2
When I cov2 execute strategize with plan_id "PLAN02" and definition "Step A\nStep B\nStep C"
Then the cov2 strategize result should have "3" decisions
And the cov2 root decision should have no parent
And the cov2 non-root decisions should reference root as parent
Scenario: PlanExecutor changeset_store is None without execution context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor changeset_store should be None
Scenario: Strategize stub with invariants produces invariant records
Given a fresh StrategizeStubActor for cov2
When I cov2 execute strategize with invariants
Then the cov2 strategize result should have "2" invariant records
And each cov2 invariant record should have enforced set to true
# --- PlanExecutor.run_strategize happy path ---
Scenario: Strategize stub emits all stream events
Given a fresh StrategizeStubActor for cov2
And a cov2 stream event collector
When I cov2 execute strategize with stream callback
Then the cov2 stream events should include "strategize_started"
And the cov2 stream events should include "strategize_decisions"
And the cov2 stream events should include "strategize_complete"
Scenario: PlanExecutor run_strategize succeeds for a plan in Strategize phase
Given a mock lifecycle with a plan in Strategize phase for executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_strategize on the PlanExecutor for coverage
Then the strategize result should be returned successfully
And the lifecycle should have called start_strategize for coverage
And the lifecycle should have called complete_strategize for coverage
Scenario: Strategize stub without stream callback does not error
Given a fresh StrategizeStubActor for cov2
When I cov2 execute strategize with plan_id "PLAN03" and definition "Do thing" without callback
Then the cov2 strategize result should have "1" decisions
# --- PlanExecutor.run_strategize exception path (line 273) ---
# ------------------------------------------------------------------
# ExecuteStubActor - tool_runner, sandbox_root, stream, read_only
# ------------------------------------------------------------------
Scenario: PlanExecutor run_strategize handles exception and calls fail_strategize
Given a mock lifecycle with a plan in Strategize phase that will fail during execute
And a PlanExecutor using that failing lifecycle for coverage
When I call run_strategize expecting an exception for coverage
Then the lifecycle should have called fail_strategize for coverage
Scenario: Execute stub rejects empty plan_id
Given a fresh ExecuteStubActor for cov2
When I cov2 execute execute-stub with empty plan_id
Then a cov2 ValidationError should be raised with message containing "plan_id"
# --- PlanExecutor.run_strategize wrong phase ---
Scenario: Execute stub with tool_runner counts discovered tools
Given a fresh ExecuteStubActor for cov2
And a cov2 mock tool runner that discovers 3 tools
When I cov2 execute execute-stub with tool_runner and 2 decisions
Then the cov2 execute result tool_calls_count should be "6"
Scenario: PlanExecutor run_strategize rejects plan not in Strategize phase
Given a mock lifecycle with a plan in Execute phase for executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_strategize expecting a PlanError for coverage
Then a PlanError should be raised about wrong phase for coverage
Scenario: Execute stub with sandbox_root populates sandbox_refs
Given a fresh ExecuteStubActor for cov2
When I cov2 execute execute-stub with sandbox_root "/tmp/sandbox"
Then the cov2 execute result sandbox_refs should contain "/tmp/sandbox"
# --- PlanExecutor._guard_execute: decision_root_id is None (lines 233-235) ---
Scenario: Execute stub without sandbox_root has empty sandbox_refs
Given a fresh ExecuteStubActor for cov2
When I cov2 execute execute-stub without sandbox_root
Then the cov2 execute result sandbox_refs should be empty
Scenario: Guard execute rejects plan with no decision root id
Given a mock lifecycle with an execute-phase plan missing decision_root_id
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute for the plan for coverage
Then a PlanError should be raised about missing decision tree
Scenario: Execute stub emits all stream events for each decision
Given a fresh ExecuteStubActor for cov2
And a cov2 stream event collector
When I cov2 execute execute-stub with 2 decisions and stream callback
Then the cov2 stream events should include "execute_started"
And the cov2 stream events should include "execute_step"
And the cov2 stream events should include "execute_complete"
Scenario: Guard execute rejects plan not in Execute phase
Given a mock lifecycle with a plan in Strategize phase for guard coverage
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute expecting wrong phase for coverage
Then a PlanError should be raised about not in Execute phase
Scenario: Execute stub with read_only passes to changeset capture
Given a fresh ExecuteStubActor for cov2
When I cov2 execute execute-stub with read_only true
Then the cov2 execute result should have a valid changeset_id
Scenario: Guard execute rejects plan not in queued state
Given a mock lifecycle with an execute-phase plan in processing state for coverage
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute expecting wrong state for coverage
Then a PlanError should be raised about not queued
# ------------------------------------------------------------------
# PlanExecutor.__init__ - validation
# ------------------------------------------------------------------
# --- PlanExecutor._run_execute_with_stub happy path ---
Scenario: PlanExecutor rejects None lifecycle_service
When I cov2 construct PlanExecutor with None lifecycle
Then a cov2 ValidationError should be raised with message containing "lifecycle_service"
Scenario: Run execute with stub succeeds for a properly configured plan
Given a mock lifecycle with a fully configured execute-phase plan for coverage
And a PlanExecutor using that lifecycle without runtime for coverage
When I call run_execute on the PlanExecutor for coverage
Then the execute result should be returned successfully
And the lifecycle should have called start_execute for coverage
And the lifecycle should have called complete_execute for coverage
Scenario: PlanExecutor initialises with valid lifecycle
Given a cov2 mock lifecycle service
When I cov2 construct PlanExecutor with that lifecycle
Then the cov2 PlanExecutor should be initialised without error
# --- PlanExecutor._run_execute_with_stub exception path (lines 483, 490) ---
# ------------------------------------------------------------------
# PlanExecutor properties
# ------------------------------------------------------------------
Scenario: Run execute with stub handles exception and calls fail_execute
Given a mock lifecycle with an execute-phase plan that will fail during stub execute
And a PlanExecutor using that failing stub lifecycle for coverage
When I call run_execute expecting an exception from stub for coverage
Then the lifecycle should have called fail_execute for coverage
Scenario: has_runtime is False without execution context
Given a cov2 mock lifecycle service
And a cov2 PlanExecutor without execution context
Then the cov2 executor has_runtime should be "False"
# --- PlanExecutor.run_execute validation ---
Scenario: has_runtime is True with execution context
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 PlanExecutor with execution context
Then the cov2 executor has_runtime should be "True"
Scenario: PlanExecutor run_execute rejects empty plan_id
Given a fresh mock lifecycle for plan executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_execute with an empty plan_id for coverage
Then a plan executor ValidationError should be raised containing "plan_id"
Scenario: changeset_store is None without execution context
Given a cov2 mock lifecycle service
And a cov2 PlanExecutor without execution context
Then the cov2 executor changeset_store should be None
# --- PlanExecutor._build_decisions ---
Scenario: changeset_store returns store from execution context
Given a cov2 mock lifecycle service
And a cov2 mock execution context with changeset store
And a cov2 PlanExecutor with that execution context
Then the cov2 executor changeset_store should be the mock store
Scenario: Build decisions creates decisions from plan definition of done
Given a mock lifecycle with a fully configured execute-phase plan for coverage
And a PlanExecutor using that lifecycle without runtime for coverage
When I call build_decisions for the plan for coverage
Then the decisions should match the plan definition of done steps
Scenario: execution_context property returns None without context
Given a cov2 mock lifecycle service
And a cov2 PlanExecutor without execution context
Then the cov2 executor execution_context should be None
# --- PlanExecutor.execution_context property ---
Scenario: execution_context property returns context when set
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 PlanExecutor with execution context
Then the cov2 executor execution_context should not be None
Scenario: PlanExecutor execution_context returns None without context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor execution_context should be None
# ------------------------------------------------------------------
# PlanExecutor.run_strategize - guards and happy path
# ------------------------------------------------------------------
Scenario: run_strategize rejects empty plan_id
Given a cov2 mock lifecycle service
And a cov2 PlanExecutor without execution context
When I cov2 call run_strategize with empty plan_id
Then a cov2 ValidationError should be raised with message containing "plan_id"
Scenario: run_strategize rejects plan not in Strategize phase
Given a cov2 mock lifecycle service
And a cov2 plan in Execute phase
And a cov2 PlanExecutor without execution context
When I cov2 call run_strategize
Then a cov2 PlanError should be raised containing "not in Strategize phase"
Scenario: run_strategize happy path succeeds
Given a cov2 mock lifecycle service
And a cov2 plan in Strategize phase with definition "Build feature\nAdd tests"
And a cov2 PlanExecutor without execution context
When I cov2 call run_strategize
Then the cov2 strategize run result should be a StrategizeResult
And the cov2 lifecycle should have called start_strategize
And the cov2 lifecycle should have called complete_strategize
And the cov2 lifecycle should have called _commit_plan
Scenario: run_strategize sets decision_root_id on execution context
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 plan in Strategize phase with definition "Do work"
And a cov2 PlanExecutor with execution context
When I cov2 call run_strategize
Then the cov2 execution context decision_root_id should be set
# ------------------------------------------------------------------
# PlanExecutor.run_strategize - exception path
# ------------------------------------------------------------------
Scenario: run_strategize calls fail_strategize on exception
Given a cov2 mock lifecycle service
And a cov2 plan in Strategize phase with definition "Do work"
And a cov2 PlanExecutor with failing strategize actor
When I cov2 call run_strategize expecting exception
Then a cov2 RuntimeError should have been raised
And the cov2 lifecycle should have called fail_strategize
And the cov2 plan error_details should contain exception_type
Scenario: run_strategize records error via error_recovery service
Given a cov2 mock lifecycle service
And a cov2 plan in Strategize phase with definition "Do work"
And a cov2 mock error recovery service
And a cov2 PlanExecutor with error recovery and failing strategize actor
When I cov2 call run_strategize expecting exception
Then the cov2 error recovery should have recorded a strategize error
# ------------------------------------------------------------------
# PlanExecutor._guard_execute - all branches
# ------------------------------------------------------------------
Scenario: guard_execute rejects plan not in Execute phase
Given a cov2 mock lifecycle service
And a cov2 plan in Strategize phase with definition "Do work"
And a cov2 PlanExecutor without execution context
When I cov2 call guard_execute
Then a cov2 PlanError should be raised containing "not in Execute phase"
Scenario: guard_execute rejects plan not in queued state
Given a cov2 mock lifecycle service
And a cov2 plan in Execute phase but Processing state
And a cov2 PlanExecutor without execution context
When I cov2 call guard_execute
Then a cov2 PlanError should be raised containing "not queued"
Scenario: guard_execute rejects plan with no decision_root_id
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state without decision root
And a cov2 PlanExecutor without execution context
When I cov2 call guard_execute
Then a cov2 PlanError should be raised containing "no decision tree"
# ------------------------------------------------------------------
# PlanExecutor.run_execute - routing
# ------------------------------------------------------------------
Scenario: run_execute rejects empty plan_id
Given a cov2 mock lifecycle service
And a cov2 PlanExecutor without execution context
When I cov2 call run_execute with empty plan_id
Then a cov2 ValidationError should be raised with message containing "plan_id"
Scenario: run_execute routes to stub when no execution context
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor without execution context
When I cov2 call run_execute
Then the cov2 execute run result should be an ExecuteResult
And the cov2 lifecycle should have called complete_execute
# ------------------------------------------------------------------
# PlanExecutor._run_execute_with_runtime
# ------------------------------------------------------------------
Scenario: run_execute routes to runtime when execution context is set
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with execution context and tool runner
When I cov2 call run_execute
Then the cov2 execute run result should be a RuntimeExecuteResult
And the cov2 lifecycle should have called complete_execute
Scenario: run_execute runtime mode raises PlanError when no ToolRunner
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with execution context but no tool runner
When I cov2 call run_execute expecting exception
Then a cov2 PlanError should be raised containing "ToolRunner"
Scenario: run_execute runtime mode calls fail_execute on exception
Given a cov2 mock lifecycle service
And a cov2 mock execution context
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with execution context and tool runner and failing runtime
When I cov2 call run_execute expecting exception
Then a cov2 RuntimeError should have been raised
And the cov2 lifecycle should have called fail_execute
# ------------------------------------------------------------------
# PlanExecutor._run_execute_with_stub - error recovery / retry
# ------------------------------------------------------------------
Scenario: run_execute stub calls fail_execute on exception without recovery
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with failing execute actor and no recovery
When I cov2 call run_execute expecting exception
Then a cov2 RuntimeError should have been raised
And the cov2 lifecycle should have called fail_execute
Scenario: run_execute stub retries once with error recovery then succeeds
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with execute actor that fails once then succeeds
When I cov2 call run_execute
Then the cov2 execute run result should be an ExecuteResult
And the cov2 error recovery should have recorded an execute error
Scenario: run_execute stub exhausts retries with error recovery and fails
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with always-failing execute actor and exhausted retries
When I cov2 call run_execute expecting exception
Then a cov2 RuntimeError should have been raised
And the cov2 lifecycle should have called fail_execute
And the cov2 error recovery should have recorded an execute error
Scenario: run_execute stub with error recovery records error and no retry
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor with failing execute actor and recovery denying retry
When I cov2 call run_execute expecting exception
Then a cov2 RuntimeError should have been raised
And the cov2 error recovery should have recorded an execute error
And the cov2 lifecycle should have called fail_execute
# ------------------------------------------------------------------
# PlanExecutor._build_decisions
# ------------------------------------------------------------------
Scenario: _build_decisions creates decisions from plan definition_of_done
Given a cov2 mock lifecycle service
And a cov2 plan in Execute-Queued state with decision root and definition
And a cov2 PlanExecutor without execution context
When I cov2 call build_decisions
Then the cov2 built decisions should have "2" entries
And the cov2 first built decision should use the plan decision_root_id
And the cov2 second built decision should have root as parent
# ------------------------------------------------------------------
# Result model edge cases
# ------------------------------------------------------------------
Scenario: StrategyDecision model validates sequence is non-negative
When I cov2 create a StrategyDecision with negative sequence
Then a cov2 validation error should be raised for the model
Scenario: ExecuteResult defaults are correct
When I cov2 create an ExecuteResult with minimal fields
Then the cov2 ExecuteResult tool_calls_count should default to "0"
And the cov2 ExecuteResult sandbox_refs should default to empty list
+232
View File
@@ -0,0 +1,232 @@
@unit @repository @coverage
Feature: Repository coverage for ActionRepository and LifecyclePlanRepository
Exercise uncovered lines and branches in repositories.py to boost
line-rate and branch-rate for ActionRepository and LifecyclePlanRepository.
Background:
Given a repos-cov in-memory database with lifecycle schema
And a repos-cov action repository
And a repos-cov lifecycle plan repository
# ---------------------------------------------------------------------------
# ActionRepository.get_by_name DatabaseError on OperationalError
# ---------------------------------------------------------------------------
@action @error_handling
Scenario: ActionRepository.get_by_name raises DatabaseError on OperationalError
Given a repos-cov action repository with a session that raises OperationalError on query
When repos-cov get_by_name is called with "local/broken-action"
Then repos-cov a DatabaseError should be raised containing "Failed to get action by name"
# ---------------------------------------------------------------------------
# ActionRepository.get_by_name success path returns domain object
# ---------------------------------------------------------------------------
@action
Scenario: ActionRepository.get_by_name returns domain object for existing action
Given repos-cov an available action "local/findable" exists
When repos-cov get_by_name is called with "local/findable"
Then repos-cov the returned action should have name "local/findable"
# ---------------------------------------------------------------------------
# ActionRepository.get_by_namespace with state filter returning results
# ---------------------------------------------------------------------------
@action
Scenario: ActionRepository.get_by_namespace with state filter returns matching actions
Given repos-cov an action "local/active-one" exists with state "available"
And repos-cov an action "local/archived-one" exists with state "archived"
When repos-cov get_by_namespace is called with namespace "local" and state "available"
Then repos-cov exactly 1 action should be in the result list
And repos-cov the result list should contain action named "local/active-one"
# ---------------------------------------------------------------------------
# ActionRepository.get_by_namespace DatabaseError on OperationalError
# ---------------------------------------------------------------------------
@action @error_handling
Scenario: ActionRepository.get_by_namespace raises DatabaseError on OperationalError
Given a repos-cov action repository with a session that raises OperationalError on query
When repos-cov get_by_namespace is called with namespace "local" expecting error
Then repos-cov a DatabaseError should be raised containing "Failed to list actions in namespace"
# ---------------------------------------------------------------------------
# ActionRepository.list_available without namespace filter
# ---------------------------------------------------------------------------
@action
Scenario: ActionRepository.list_available returns all available actions without namespace
Given repos-cov an action "ns1/act-a" exists with state "available"
And repos-cov an action "ns2/act-b" exists with state "available"
And repos-cov an action "ns1/act-c" exists with state "archived"
When repos-cov list_available is called without a namespace filter
Then repos-cov exactly 2 actions should be in the result list
# ---------------------------------------------------------------------------
# ActionRepository.list_available with namespace filter
# ---------------------------------------------------------------------------
@action
Scenario: ActionRepository.list_available returns available actions filtered by namespace
Given repos-cov an action "nsx/avail-1" exists with state "available"
And repos-cov an action "nsy/avail-2" exists with state "available"
When repos-cov list_available is called with namespace "nsx"
Then repos-cov exactly 1 action should be in the result list
# ---------------------------------------------------------------------------
# ActionRepository.list_available DatabaseError on OperationalError
# ---------------------------------------------------------------------------
@action @error_handling
Scenario: ActionRepository.list_available raises DatabaseError on OperationalError
Given a repos-cov action repository with a session that raises OperationalError on query
When repos-cov list_available is called and an error is expected
Then repos-cov a DatabaseError should be raised containing "Failed to list available actions"
# ---------------------------------------------------------------------------
# ActionRepository.update with arguments having min_value and max_value
# ---------------------------------------------------------------------------
@action @update
Scenario: ActionRepository.update persists arguments with min_value and max_value
Given repos-cov an available action "local/minmax-action" exists
And repos-cov the action has arguments with min_value and max_value set
When repos-cov the action is updated via the repository
Then repos-cov the update should succeed
And repos-cov the retrieved action arguments should have min_value and max_value
# ---------------------------------------------------------------------------
# ActionRepository.delete ActionInUseError when plans reference the action
# ---------------------------------------------------------------------------
@action @delete @error_handling
Scenario: ActionRepository.delete raises ActionInUseError when plans reference the action
Given repos-cov an available action "local/in-use-action" exists
And a repos-cov lifecycle plan references action "local/in-use-action"
When repos-cov delete is called for action "local/in-use-action"
Then repos-cov an ActionInUseError should be raised for "local/in-use-action"
# ---------------------------------------------------------------------------
# ActionRepository.delete successful deletion when no plans reference it
# ---------------------------------------------------------------------------
@action @delete
Scenario: ActionRepository.delete succeeds and returns True for unreferenced action
Given repos-cov an available action "local/deletable" exists
When repos-cov delete is called for action "local/deletable"
Then repos-cov the delete result should be True
And repos-cov get_by_id for "local/deletable" should return None
# ---------------------------------------------------------------------------
# ActionRepository.delete returns False for non-existent action
# ---------------------------------------------------------------------------
@action @delete
Scenario: ActionRepository.delete returns False for non-existent action
When repos-cov delete is called for action "local/ghost"
Then repos-cov the delete result should be False
# ---------------------------------------------------------------------------
# DuplicatePlanError instantiation
# ---------------------------------------------------------------------------
@plan @error_class
Scenario: DuplicatePlanError stores plan_id and message
When repos-cov a DuplicatePlanError is created for plan_id "01HGZ6FE0A00000000TESTPLAN"
Then repos-cov the error message should contain "01HGZ6FE0A00000000TESTPLAN"
And repos-cov the error plan_id attribute should be "01HGZ6FE0A00000000TESTPLAN"
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.get_by_name success path
# ---------------------------------------------------------------------------
@plan
Scenario: LifecyclePlanRepository.get_by_name returns domain object for existing plan
Given repos-cov an available action "local/plan-target" exists
And a repos-cov lifecycle plan linked to "local/plan-target" with name "local/my-plan" has been persisted
When repos-cov plan get_by_name is called with "local/my-plan"
Then repos-cov the returned plan should not be None
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.get_by_name returns None for missing
# ---------------------------------------------------------------------------
@plan
Scenario: LifecyclePlanRepository.get_by_name returns None for non-existent plan
When repos-cov plan get_by_name is called with "local/no-such-plan"
Then repos-cov the returned plan should be None
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.get_by_name DatabaseError on OperationalError
# ---------------------------------------------------------------------------
@plan @error_handling
Scenario: LifecyclePlanRepository.get_by_name raises DatabaseError on OperationalError
Given a repos-cov plan repository with a session that raises OperationalError on query
When repos-cov plan get_by_name is called with "local/error-plan" expecting error
Then repos-cov a DatabaseError should be raised containing "Failed to get plan by name"
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.update full update with project_links, arguments, invariants
# ---------------------------------------------------------------------------
@plan @update
Scenario: LifecyclePlanRepository.update persists project links, arguments, and invariants
Given repos-cov an available action "local/plan-upd-action" exists
And a repos-cov lifecycle plan linked to "local/plan-upd-action" with name "local/upd-plan" has been persisted
And repos-cov the plan has project_links, arguments, and invariants with text attribute
When repos-cov the plan is updated via the repository
Then repos-cov the plan update should succeed
And repos-cov the retrieved plan should have project links
And repos-cov the retrieved plan should have arguments
And repos-cov the retrieved plan should have invariants
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.update invariants as plain strings (no .text attr)
# ---------------------------------------------------------------------------
@plan @update
Scenario: LifecyclePlanRepository.update handles invariants as plain strings
Given repos-cov an available action "local/plan-str-inv" exists
And a repos-cov lifecycle plan linked to "local/plan-str-inv" with name "local/str-inv-plan" has been persisted
And repos-cov the plan has invariants as plain strings without text attribute
When repos-cov the plan is updated via the repository
Then repos-cov the plan update should succeed
And repos-cov the retrieved plan should have invariants
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.update invariant source with .value attribute
# ---------------------------------------------------------------------------
@plan @update
Scenario: LifecyclePlanRepository.update handles invariant source with .value enum
Given repos-cov an available action "local/plan-enum-inv" exists
And a repos-cov lifecycle plan linked to "local/plan-enum-inv" with name "local/enum-inv-plan" has been persisted
And repos-cov the plan has invariants with InvariantSource enum values
When repos-cov the plan is updated via the repository
Then repos-cov the plan update should succeed
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.update processing_state without .value (plain str)
# ---------------------------------------------------------------------------
@plan @update
Scenario: LifecyclePlanRepository.update handles processing_state as plain string
Given repos-cov an available action "local/plan-str-state" exists
And a repos-cov lifecycle plan linked to "local/plan-str-state" with name "local/str-state-plan" has been persisted
And repos-cov the plan processing_state is set to a plain string "processing"
When repos-cov the plan is updated via the repository
Then repos-cov the plan update should succeed
# ---------------------------------------------------------------------------
# LifecyclePlanRepository.update error_details and changeset_id fields
# ---------------------------------------------------------------------------
@plan @update
Scenario: LifecyclePlanRepository.update persists error_details and changeset_id
Given repos-cov an available action "local/plan-err-detail" exists
And a repos-cov lifecycle plan linked to "local/plan-err-detail" with name "local/err-detail-plan" has been persisted
And repos-cov the plan has error_message, error_details, and changeset_id set
When repos-cov the plan is updated via the repository
Then repos-cov the plan update should succeed
And repos-cov the retrieved plan should have the error_message set
@@ -0,0 +1,146 @@
Feature: CopyOnWriteSandbox uncovered lines and branches
As a developer
I want to exercise every uncovered code path in copy_on_write.py
So that line and branch coverage reaches 100%
# ------------------------------------------------------------------
# create() OSError from shutil.copytree wraps into SandboxCreationError
# Covers lines 143-145
# ------------------------------------------------------------------
Scenario: create wraps OSError from copytree into SandboxCreationError
Given a cowcov test directory is initialised
And shutil.copytree is patched to raise OSError in cowcov
When a cowcov sandbox create is attempted for plan "plan-oserr"
Then a cowcov SandboxCreationError should be raised
And the cowcov error message should contain "Failed to copy directory"
And the cowcov sandbox status should be "errored"
# ------------------------------------------------------------------
# get_path() — second call when already ACTIVE (line 196 False branch)
# Covers missing branch 196 → 199
# ------------------------------------------------------------------
Scenario: get_path called twice stays in ACTIVE status
Given a cowcov test directory is initialised
And a cowcov sandbox is created for plan "plan-active"
When cowcov get_path is called with "file1.txt"
And cowcov get_path is called with "file2.txt"
Then the cowcov sandbox status should be "active"
And both cowcov resolved paths should be valid
# ------------------------------------------------------------------
# get_path() — _sandbox_path is None with ACTIVE status
# Covers line 200 and branch 196 False + 199 True
# ------------------------------------------------------------------
Scenario: get_path raises SandboxStateError when ACTIVE but sandbox_path is None
Given a cowcov sandbox forced to ACTIVE with sandbox_path None
When cowcov get_path is attempted with "file.txt"
Then a cowcov SandboxStateError should be raised with message "Sandbox path not set"
# ------------------------------------------------------------------
# get_path() — _sandbox_path is None with CREATED status
# Covers line 200 and branch 199 True
# ------------------------------------------------------------------
Scenario: get_path raises SandboxStateError when CREATED but sandbox_path is None
Given a cowcov sandbox forced to CREATED with sandbox_path None
When cowcov get_path is attempted with "data.csv"
Then a cowcov SandboxStateError should be raised with message "Sandbox path not set"
# ------------------------------------------------------------------
# commit() — _sandbox_path is None
# Covers line 230
# ------------------------------------------------------------------
Scenario: commit raises SandboxStateError when sandbox_path is None
Given a cowcov sandbox forced to ACTIVE with sandbox_path None
When cowcov commit is attempted
Then a cowcov SandboxStateError should be raised with message "Sandbox path not set"
# ------------------------------------------------------------------
# commit() — dst_dir is empty (branch 242 False → 244)
# Covers missing branch at line 242
# ------------------------------------------------------------------
Scenario: commit skips makedirs when dst_dir is empty string
Given a cowcov test directory is initialised
And a cowcov sandbox is created for plan "plan-dstdir"
And cowcov _compute_diff is patched to return a changed root file
And cowcov os.path.dirname is patched to return empty string
And cowcov shutil.copy2 is patched to no-op
When cowcov commit is attempted
Then the cowcov commit should succeed
And cowcov os.makedirs should not have been called
# ------------------------------------------------------------------
# commit() — deleted file not present in original (branch 249 False)
# Covers missing branch at line 249 → back to 247
# ------------------------------------------------------------------
Scenario: commit handles deletion when file already missing from original
Given a cowcov test directory is initialised
And a cowcov sandbox is created and activated for plan "plan-delmiss"
When the cowcov file "existing.txt" is deleted from both sandbox and original
And cowcov commit is attempted
Then the cowcov commit should succeed
# ------------------------------------------------------------------
# commit() — OSError during sync wraps into SandboxCommitError
# Covers lines 252-254
# ------------------------------------------------------------------
Scenario: commit wraps OSError during file sync into SandboxCommitError
Given a cowcov test directory is initialised
And a cowcov sandbox is created and activated for plan "plan-syncerr"
And a cowcov file "existing.txt" is modified in the sandbox
And cowcov shutil.copy2 is patched to raise OSError
When cowcov commit is attempted
Then a cowcov SandboxCommitError should be raised
And the cowcov sandbox status should be "errored"
# ------------------------------------------------------------------
# rollback() — _sandbox_path is None
# Covers line 297
# ------------------------------------------------------------------
Scenario: rollback raises SandboxStateError when sandbox_path is None
Given a cowcov sandbox forced to ACTIVE with sandbox_path None
When cowcov rollback is attempted
Then a cowcov SandboxStateError should be raised with message "Sandbox path not set"
# ------------------------------------------------------------------
# rollback() — OSError during re-copy wraps into SandboxRollbackError
# Covers lines 311-313
# ------------------------------------------------------------------
Scenario: rollback wraps OSError from copytree into SandboxRollbackError
Given a cowcov test directory is initialised
And a cowcov sandbox is created and activated for plan "plan-rbfail"
And cowcov shutil.copytree is patched to raise OSError for rollback
When cowcov rollback is attempted
Then a cowcov SandboxRollbackError should be raised
And the cowcov sandbox status should be "errored"
# ------------------------------------------------------------------
# cleanup() — _sandbox_path is None (branch 341 False → 347)
# Covers missing branch at line 341
# ------------------------------------------------------------------
Scenario: cleanup succeeds when sandbox_path was never set
Given a cowcov sandbox in PENDING state with sandbox_path None
When cowcov cleanup is called
Then the cowcov sandbox status should be "cleaned_up"
# ------------------------------------------------------------------
# cleanup() — parent directory already gone (branch 344 False → 347)
# Covers missing branch at line 344
# ------------------------------------------------------------------
Scenario: cleanup succeeds when parent directory is already removed
Given a cowcov test directory is initialised
And a cowcov sandbox is created for plan "plan-cleanmissing"
And the cowcov sandbox parent directory is manually removed
When cowcov cleanup is called
Then the cowcov sandbox status should be "cleaned_up"
+248
View File
@@ -0,0 +1,248 @@
Feature: Skill CLI coverage round 3
Cover remaining uncovered branches in src/cleveragents/cli/commands/skill.py:
tools --refresh flag, list/show non-rich capability error fallback,
refresh with agent_skills_paths discovery, refresh errors, long
description truncation, and the tools ValueError path.
Background:
Given r3skill- a fresh skill CLI service
# -------------------------------------------------------------------
# tools --refresh flag with agent_skills_paths configured (lines 815-838)
# -------------------------------------------------------------------
Scenario: Tools command with --refresh and configured agent_skills paths
Given r3skill- a registered skill "local/refreshable" with tool_refs
And r3skill- agent_skills_paths config resolves to a valid path with discovered skills
When r3skill- I invoke tools "local/refreshable" with --refresh
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Refreshed"
And r3skill- the output should contain "agent skill"
Scenario: Tools command with --refresh and empty agent_skills_paths
Given r3skill- a registered skill "local/no-agent-paths" with tool_refs
And r3skill- agent_skills_paths config resolves to empty string
When r3skill- I invoke tools "local/no-agent-paths" with --refresh
Then r3skill- the CLI exit code should be 0
Scenario: Tools command with --refresh and discovery errors
Given r3skill- a registered skill "local/disc-errors" with tool_refs
And r3skill- agent_skills_paths config resolves with discovery errors
When r3skill- I invoke tools "local/disc-errors" with --refresh
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Warning"
# -------------------------------------------------------------------
# tools ValueError path (lines 887-889)
# -------------------------------------------------------------------
Scenario: Tools command when resolve_tools raises ValueError
Given r3skill- a registered skill "local/val-err-tools" with tool_refs
And r3skill- resolve_tools will raise ValueError for "local/val-err-tools"
When r3skill- I invoke tools "local/val-err-tools" in rich format
Then r3skill- the CLI exit code should not be 0
And r3skill- the output should contain "Resolution error"
# -------------------------------------------------------------------
# list non-rich format: capability summary ValueError fallback (lines 667-669)
# -------------------------------------------------------------------
Scenario: List with JSON format falls back when capability summary raises ValueError
Given r3skill- a registered skill "local/cap-fallback" with tool_refs
And r3skill- compute_capability_summary will raise ValueError
When r3skill- I invoke list in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON list first item should have key "tool_count"
And r3skill- the JSON list first item "capability_summary" should be null
# -------------------------------------------------------------------
# show non-rich format: capability summary ValueError fallback (lines 764-766)
# -------------------------------------------------------------------
Scenario: Show with JSON format falls back when capability summary raises ValueError
Given r3skill- a registered skill "local/show-cap-err" with tool_refs
And r3skill- compute_capability_summary will raise ValueError
When r3skill- I invoke show "local/show-cap-err" in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON output key "capability_summary" should be null
# -------------------------------------------------------------------
# list rich format: long description truncation (lines 696-700)
# -------------------------------------------------------------------
Scenario: List in rich format truncates long descriptions
Given r3skill- a registered skill "local/long-desc" with a description longer than 50 chars
When r3skill- I invoke list in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the long description in the output should be truncated
# -------------------------------------------------------------------
# refresh: agent_skills_paths discovery (lines 953-980)
# -------------------------------------------------------------------
Scenario: Refresh single skill with agent_skills_paths discovery
Given r3skill- a registered skill "local/refresh-agent" with tool_refs
And r3skill- agent_skills_paths config resolves to a valid path with discovered skills
When r3skill- I invoke refresh "local/refresh-agent" in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Agent Skills"
And r3skill- the output should contain "Discovered"
Scenario: Refresh all skills with agent_skills_paths and discovery errors
Given r3skill- a registered skill "local/refresh-err" with tool_refs
And r3skill- agent_skills_paths config resolves with discovery errors
When r3skill- I invoke refresh --all in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Warning"
# -------------------------------------------------------------------
# refresh: single skill resolve_tools ValueError (lines 1017-1026, 1044-1046)
# -------------------------------------------------------------------
Scenario: Refresh single skill with ValueError during resolve_tools
Given r3skill- a registered skill "local/refresh-fail" with tool_refs
And r3skill- resolve_tools will raise ValueError for "local/refresh-fail"
When r3skill- I invoke refresh "local/refresh-fail" in rich format
Then r3skill- the CLI exit code should not be 0
And r3skill- the output should contain "Refresh failed"
# -------------------------------------------------------------------
# refresh: all skills with one skill ValueError (lines 1017-1026 in loop)
# -------------------------------------------------------------------
Scenario: Refresh all skills where one skill has resolve_tools ValueError
Given r3skill- a registered skill "local/good-skill" with tool_refs
And r3skill- a registered skill "local/bad-skill" with tool_refs
And r3skill- resolve_tools will raise ValueError for "local/bad-skill"
When r3skill- I invoke refresh --all in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Errors"
# -------------------------------------------------------------------
# refresh: non-rich format with mcp_errors (lines 1035-1036)
# -------------------------------------------------------------------
Scenario: Refresh all in JSON format with mcp errors
Given r3skill- a registered skill "local/ok-skill" with tool_refs
And r3skill- a registered skill "local/err-skill" with tool_refs
And r3skill- resolve_tools will raise ValueError for "local/err-skill"
When r3skill- I invoke refresh --all in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON output should have key "errors"
# -------------------------------------------------------------------
# refresh: general Exception catch (lines 1106-1108)
# -------------------------------------------------------------------
Scenario: Refresh raises unexpected Exception
Given r3skill- a registered skill "local/exc-skill" with tool_refs
And r3skill- list_skills will raise RuntimeError
When r3skill- I invoke refresh --all in rich format
Then r3skill- the CLI exit code should not be 0
And r3skill- the output should contain "Refresh error"
# -------------------------------------------------------------------
# refresh: agent_skills_paths empty path (lines 961-962 false branch)
# -------------------------------------------------------------------
Scenario: Refresh with empty agent_skills_paths skips discovery
Given r3skill- a registered skill "local/no-discovery" with tool_refs
And r3skill- agent_skills_paths config resolves to empty string
When r3skill- I invoke refresh "local/no-discovery" in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should not contain "Discovered"
# -------------------------------------------------------------------
# refresh: non-rich single skill (lines 1029-1037)
# -------------------------------------------------------------------
Scenario: Refresh single skill with JSON format returns structured output
Given r3skill- a registered skill "local/json-refresh" with tool_refs
When r3skill- I invoke refresh "local/json-refresh" in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON output should have key "refreshed"
# -------------------------------------------------------------------
# _print_skill_detail: both timestamps None (lines 253-254)
# -------------------------------------------------------------------
Scenario: Show skill in rich format with no timestamps shows unknown
Given r3skill- a registered skill "local/no-ts" with tool_refs
And r3skill- timestamps for "local/no-ts" are removed
When r3skill- I invoke show "local/no-ts" in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "unknown"
# -------------------------------------------------------------------
# _print_skill_registered: include present AND registered (line 159 true)
# -------------------------------------------------------------------
Scenario: Add skill with includes where included skill IS registered
Given r3skill- a registered skill "local/base-inc" with tool_refs
And r3skill- a temp YAML for "local/parent-inc" that includes "local/base-inc"
When r3skill- I invoke add with the temp config in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "registered"
And r3skill- the output should contain "Includes"
# -------------------------------------------------------------------
# _print_skill_update_changes: old_skill with includes/mcp changed
# -------------------------------------------------------------------
Scenario: Update skill changes includes and MCP servers shows diff
Given r3skill- a temp YAML for "local/upd-diff" with tool_refs and MCP
And r3skill- the skill "local/upd-diff" is pre-registered via add
And r3skill- a second temp YAML for "local/upd-diff" with different includes and MCP
When r3skill- I invoke add with the second config and --update in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Changes"
And r3skill- the output should contain "Includes Changed"
And r3skill- the output should contain "MCP Servers Changed"
# -------------------------------------------------------------------
# _print_skill_tools: inline tool with writes and checkpointable
# -------------------------------------------------------------------
Scenario: Tools command with inline tool having writes and checkpointable
Given r3skill- a registered skill "local/inline-caps" with inline tools having capabilities
When r3skill- I invoke tools "local/inline-caps" in rich format
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "custom"
# -------------------------------------------------------------------
# remove: confirm prompt with yes (line 553 true branch via input)
# -------------------------------------------------------------------
Scenario: Remove skill without --yes and confirm with y
Given r3skill- a registered skill "local/confirm-rm" with tool_refs
When r3skill- I invoke remove "local/confirm-rm" without --yes and confirm
Then r3skill- the CLI exit code should be 0
And r3skill- the output should contain "Skill Removed"
# -------------------------------------------------------------------
# list non-rich: capability summary KeyError fallback
# -------------------------------------------------------------------
Scenario: List with JSON format falls back when capability summary raises KeyError
Given r3skill- a registered skill "local/key-err-list" with tool_refs
And r3skill- compute_capability_summary will raise KeyError
When r3skill- I invoke list in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON list first item "capability_summary" should be null
# -------------------------------------------------------------------
# show non-rich: capability summary KeyError fallback
# -------------------------------------------------------------------
Scenario: Show with JSON format falls back when capability summary raises KeyError
Given r3skill- a registered skill "local/key-err-show" with tool_refs
And r3skill- compute_capability_summary will raise KeyError
When r3skill- I invoke show "local/key-err-show" in format "json"
Then r3skill- the CLI exit code should be 0
And r3skill- the output should be valid JSON
And r3skill- the JSON output key "capability_summary" should be null
+355
View File
@@ -0,0 +1,355 @@
"""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"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
"""Step definitions for lock_service_coverage.feature - uncovered branches."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.application.services.lock_service import LockService
from cleveragents.core.exceptions import (
LockConflictError,
LockExpiredError,
ValidationError,
)
from cleveragents.infrastructure.database.models import Base, LockModel
# ------------------------------------------------------------------
# Helper: build a real in-memory LockService
# ------------------------------------------------------------------
def _make_lock_service(context: Context) -> LockService:
"""Create a LockService backed by an in-memory SQLite database."""
engine = create_engine(
"sqlite:///:memory:",
echo=False,
future=True,
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _fk(dbapi_conn: Any, _rec: Any) -> None:
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(
bind=engine,
expire_on_commit=False,
autoflush=False,
autocommit=False,
class_=Session,
)
context.cov_engine = engine
context.cov_session_factory = factory
return LockService(session_factory=factory)
# ------------------------------------------------------------------
# Stub session that blows up on execute (for rollback tests)
# ------------------------------------------------------------------
class _StubResult:
"""Minimal stand-in for a SQLAlchemy result object."""
rowcount: int = 0
def scalar_one_or_none(self) -> None:
"""Return None."""
return None
def scalar_one(self) -> int:
"""Return 0."""
return 0
class _ExplodingSession:
"""Session stub that raises RuntimeError on execute."""
def __init__(self) -> None:
"""Initialise tracking flags."""
self.rolled_back: bool = False
self.committed: bool = False
def execute(self, stmt: Any) -> Any:
"""Raise RuntimeError unconditionally."""
raise RuntimeError("boom")
def add(self, obj: Any) -> None:
"""No-op."""
def commit(self) -> None:
"""Record commit."""
self.committed = True
def rollback(self) -> None:
"""Record rollback."""
self.rolled_back = True
# ------------------------------------------------------------------
# Background
# ------------------------------------------------------------------
@given("I have a fresh lock service with in-memory database")
def step_fresh_lock_service(context: Context) -> None:
"""Create a fresh in-memory LockService for coverage scenarios."""
context.cov_svc = _make_lock_service(context)
context.cov_error = None
context.cov_result = None
context.cov_session = None
# ------------------------------------------------------------------
# Validation: release
# ------------------------------------------------------------------
@when(
'I try to release with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"'
)
def step_try_release_validation(
context: Context, rtype: str, owner: str, rid: str
) -> None:
"""Attempt release with potentially invalid params."""
try:
context.cov_result = context.cov_svc.release(
owner_id=owner,
resource_type=rtype,
resource_id=rid,
)
except ValidationError as exc:
context.cov_error = exc
@when("I try to release with an empty resource_type")
def step_try_release_empty_rtype(context: Context) -> None:
"""Attempt release with empty resource_type."""
try:
context.cov_svc.release(owner_id="o", resource_type="", resource_id="r1")
except ValidationError as exc:
context.cov_error = exc
@when("I try to release with an empty resource_id")
def step_try_release_empty_rid(context: Context) -> None:
"""Attempt release with empty resource_id."""
try:
context.cov_svc.release(owner_id="o", resource_type="plan", resource_id="")
except ValidationError as exc:
context.cov_error = exc
# ------------------------------------------------------------------
# Validation: renew
# ------------------------------------------------------------------
@when('I try to renew with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"')
def step_try_renew_validation(
context: Context, rtype: str, owner: str, rid: str
) -> None:
"""Attempt renew with potentially invalid params."""
try:
context.cov_svc.renew(
owner_id=owner,
resource_type=rtype,
resource_id=rid,
)
except ValidationError as exc:
context.cov_error = exc
@when(
'I try to renew with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"'
)
def step_try_renew_bad_ttl(
context: Context, ttl: int, owner: str, rtype: str, rid: str
) -> None:
"""Attempt renew with out-of-range TTL."""
try:
context.cov_svc.renew(
owner_id=owner,
resource_type=rtype,
resource_id=rid,
ttl_seconds=ttl,
)
except ValidationError as exc:
context.cov_error = exc
@when("I try to renew with an empty resource_type")
def step_try_renew_empty_rtype(context: Context) -> None:
"""Attempt renew with empty resource_type."""
try:
context.cov_svc.renew(owner_id="o", resource_type="", resource_id="r1")
except ValidationError as exc:
context.cov_error = exc
@when("I try to renew with an empty resource_id")
def step_try_renew_empty_rid(context: Context) -> None:
"""Attempt renew with empty resource_id."""
try:
context.cov_svc.renew(owner_id="o", resource_type="plan", resource_id="")
except ValidationError as exc:
context.cov_error = exc
# ------------------------------------------------------------------
# Validation: is_locked
# ------------------------------------------------------------------
@when('I try to check is_locked with resource_type "{rtype}" resource_id "{rid}"')
def step_try_is_locked_validation(context: Context, rtype: str, rid: str) -> None:
"""Attempt is_locked with potentially invalid params."""
try:
context.cov_result = context.cov_svc.is_locked(
resource_type=rtype,
resource_id=rid,
)
except ValidationError as exc:
context.cov_error = exc
@when("I try to check is_locked with an empty resource_type")
def step_try_is_locked_empty_rtype(context: Context) -> None:
"""Attempt is_locked with empty resource_type."""
try:
context.cov_svc.is_locked(resource_type="", resource_id="r1")
except ValidationError as exc:
context.cov_error = exc
@when("I try to check is_locked with an empty resource_id")
def step_try_is_locked_empty_rid(context: Context) -> None:
"""Attempt is_locked with empty resource_id."""
try:
context.cov_svc.is_locked(resource_type="plan", resource_id="")
except ValidationError as exc:
context.cov_error = exc
# ------------------------------------------------------------------
# Validation assertion
# ------------------------------------------------------------------
@then(
'a lock_service validation error should be raised with message containing "{fragment}"'
)
def step_assert_validation_error_message(context: Context, fragment: str) -> None:
"""Assert a ValidationError was raised whose message contains fragment."""
assert isinstance(context.cov_error, ValidationError), (
f"Expected ValidationError, got {type(context.cov_error).__name__}: {context.cov_error}"
)
assert fragment in str(context.cov_error), (
f"Expected '{fragment}' in '{context.cov_error}'"
)
# ------------------------------------------------------------------
# TTL boundary / custom TTL acquire
# ------------------------------------------------------------------
@when(
'I acquire a lock with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"'
)
def step_acquire_with_ttl(
context: Context, ttl: int, owner: str, rtype: str, rid: str
) -> None:
"""Acquire a lock with an explicit TTL."""
context.cov_result = context.cov_svc.acquire(
owner_id=owner,
resource_type=rtype,
resource_id=rid,
ttl_seconds=ttl,
)
@then("the lock_service result should be true")
def step_result_true(context: Context) -> None:
"""Assert the result is True."""
assert context.cov_result is True, f"Expected True, got {context.cov_result!r}"
@then("the lock_service result should be false")
def step_result_false(context: Context) -> None:
"""Assert the result is False."""
assert context.cov_result is False, f"Expected False, got {context.cov_result!r}"
# ------------------------------------------------------------------
# Re-entrant project lock
# ------------------------------------------------------------------
@given('owner "{owner}" already holds a project lock on "{rid}"')
def step_owner_holds_project_lock(context: Context, owner: str, rid: str) -> None:
"""Pre-acquire a project lock."""
context.cov_svc.acquire(
owner_id=owner,
resource_type="project",
resource_id=rid,
)
@given('owner "{owner}" already holds a plan lock on "{rid}"')
def step_owner_holds_plan_lock(context: Context, owner: str, rid: str) -> None:
"""Pre-acquire a plan lock."""
context.cov_svc.acquire(
owner_id=owner,
resource_type="plan",
resource_id=rid,
)
# ------------------------------------------------------------------
# is_locked on expired lock
# ------------------------------------------------------------------
@given('an expired lock exists for owner "{owner}" on plan "{plan_id}"')
def step_insert_expired_lock(context: Context, owner: str, plan_id: str) -> None:
"""Insert an already-expired lock directly into the database."""
now = datetime.now(tz=UTC)
expired_at = (now - timedelta(seconds=60)).isoformat()
acquired_at = (now - timedelta(seconds=120)).isoformat()
session: Session = context.cov_session_factory()
lock = LockModel(
owner_id=owner,
resource_type="plan",
resource_id=plan_id,
acquired_at=acquired_at,
expires_at=expired_at,
)
session.add(lock)
session.commit()
session.close()
@when('I check is_locked for resource_type "{rtype}" resource_id "{rid}"')
def step_check_is_locked_cov(context: Context, rtype: str, rid: str) -> None:
"""Check is_locked on a resource."""
context.cov_result = context.cov_svc.is_locked(
resource_type=rtype,
resource_id=rid,
)
# ------------------------------------------------------------------
# Zero-count paths
# ------------------------------------------------------------------
@when('I release all locks for owner_id "{owner}"')
def step_release_all_cov(context: Context, owner: str) -> None:
"""Release all locks for an owner via the coverage service."""
context.cov_result = context.cov_svc.release_all_for_owner(owner_id=owner)
@when("I run cleanup of expired locks")
def step_cleanup_expired_cov(context: Context) -> None:
"""Run cleanup_expired via the coverage service."""
context.cov_result = context.cov_svc.cleanup_expired()
@when("I count stale locks via the service")
def step_count_stale_cov(context: Context) -> None:
"""Count stale locks via the coverage service."""
context.cov_result = context.cov_svc.count_stale_locks()
@then("the lock_service integer result should be {expected:d}")
def step_assert_int_result(context: Context, expected: int) -> None:
"""Assert the result equals an expected integer."""
assert context.cov_result == expected, (
f"Expected {expected}, got {context.cov_result!r}"
)
# ------------------------------------------------------------------
# Exception rollback paths (mocked session)
# ------------------------------------------------------------------
@given("a lock service whose session raises an unexpected error on execute")
def step_exploding_session_service(context: Context) -> None:
"""Build a LockService whose session factory returns an exploding session."""
exploding = _ExplodingSession()
context.cov_session = exploding
context.cov_svc = LockService(session_factory=lambda: exploding) # type: ignore[arg-type]
context.cov_error = None
context.cov_result = None
@when("I try to acquire and expect a RuntimeError")
def step_try_acquire_runtime_error(context: Context) -> None:
"""Attempt acquire expecting a RuntimeError from the stub session."""
try:
context.cov_svc.acquire(
owner_id="owner-x",
resource_type="plan",
resource_id="res-x",
)
except RuntimeError as exc:
context.cov_error = exc
@when("I try to release and expect a RuntimeError")
def step_try_release_runtime_error(context: Context) -> None:
"""Attempt release expecting a RuntimeError from the stub session."""
try:
context.cov_svc.release(
owner_id="owner-x",
resource_type="plan",
resource_id="res-x",
)
except RuntimeError as exc:
context.cov_error = exc
@when("I try to renew and expect a RuntimeError")
def step_try_renew_runtime_error(context: Context) -> None:
"""Attempt renew expecting a RuntimeError from the stub session."""
try:
context.cov_svc.renew(
owner_id="owner-x",
resource_type="plan",
resource_id="res-x",
)
except RuntimeError as exc:
context.cov_error = exc
@when("I try to release_all_for_owner and expect a RuntimeError")
def step_try_release_all_runtime_error(context: Context) -> None:
"""Attempt release_all_for_owner expecting RuntimeError."""
try:
context.cov_svc.release_all_for_owner(owner_id="owner-x")
except RuntimeError as exc:
context.cov_error = exc
@when("I try to cleanup_expired and expect a RuntimeError")
def step_try_cleanup_runtime_error(context: Context) -> None:
"""Attempt cleanup_expired expecting RuntimeError."""
try:
context.cov_svc.cleanup_expired()
except RuntimeError as exc:
context.cov_error = exc
@then("a RuntimeError should have been raised")
def step_assert_runtime_error(context: Context) -> None:
"""Assert that a RuntimeError was captured."""
assert isinstance(context.cov_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.cov_error).__name__}: {context.cov_error}"
)
@then("the session should have been rolled back")
def step_assert_session_rolled_back(context: Context) -> None:
"""Assert the stub session's rollback method was called."""
assert context.cov_session is not None, "No stub session on context"
assert context.cov_session.rolled_back is True, (
"Expected session.rollback() to have been called"
)
# ------------------------------------------------------------------
# Acquire conflict rollback path (real DB)
# ------------------------------------------------------------------
@given('a lock service with a real database and owner "{owner}" holds plan "{plan_id}"')
def step_real_db_with_holder(context: Context, owner: str, plan_id: str) -> None:
"""Build a real-DB lock service and pre-acquire a lock."""
context.cov_svc = _make_lock_service(context)
context.cov_error = None
context.cov_result = None
context.cov_svc.acquire(
owner_id=owner,
resource_type="plan",
resource_id=plan_id,
)
@when('I try to acquire plan "{plan_id}" as "{owner}" and capture the conflict error')
def step_try_acquire_conflict(context: Context, plan_id: str, owner: str) -> None:
"""Attempt acquire expecting a LockConflictError."""
try:
context.cov_svc.acquire(
owner_id=owner,
resource_type="plan",
resource_id=plan_id,
)
except LockConflictError as exc:
context.cov_error = exc
@then("a LockConflictError should have been raised")
def step_assert_lock_conflict(context: Context) -> None:
"""Assert that a LockConflictError was captured."""
assert isinstance(context.cov_error, LockConflictError), (
f"Expected LockConflictError, got {type(context.cov_error).__name__}"
)
@then('the conflict owner should be "{expected}"')
def step_assert_conflict_owner(context: Context, expected: str) -> None:
"""Assert the conflict error reports the expected owner."""
err = context.cov_error
assert isinstance(err, LockConflictError), "Not a LockConflictError"
assert err.owner_id == expected, (
f"Expected conflict owner '{expected}', got '{err.owner_id}'"
)
# ------------------------------------------------------------------
# Renew LockExpiredError rollback path (real DB)
# ------------------------------------------------------------------
@given(
'a lock service with a real database and an expired lock for "{owner}" on plan "{plan_id}"'
)
def step_real_db_with_expired_lock(context: Context, owner: str, plan_id: str) -> None:
"""Build a real-DB lock service and insert an expired lock."""
context.cov_svc = _make_lock_service(context)
context.cov_error = None
context.cov_result = None
now = datetime.now(tz=UTC)
expired_at = (now - timedelta(seconds=60)).isoformat()
acquired_at = (now - timedelta(seconds=120)).isoformat()
session: Session = context.cov_session_factory()
lock = LockModel(
owner_id=owner,
resource_type="plan",
resource_id=plan_id,
acquired_at=acquired_at,
expires_at=expired_at,
)
session.add(lock)
session.commit()
session.close()
@when('I try to renew plan "{plan_id}" as "{owner}" and capture the expired error')
def step_try_renew_expired_cov(context: Context, plan_id: str, owner: str) -> None:
"""Attempt renew expecting a LockExpiredError."""
try:
context.cov_svc.renew(
owner_id=owner,
resource_type="plan",
resource_id=plan_id,
)
except LockExpiredError as exc:
context.cov_error = exc
@then("a LockExpiredError should have been raised")
def step_assert_lock_expired(context: Context) -> None:
"""Assert that a LockExpiredError was captured."""
assert isinstance(context.cov_error, LockExpiredError), (
f"Expected LockExpiredError, got {type(context.cov_error).__name__}"
)
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
"""Step definitions for plan_cli_coverage.feature.
Covers uncovered lines/branches in cleveragents/cli/commands/plan.py:
- Lines 818, 827-828: legacy apply Progress block and PlanError handler
- Branch 999: legacy list when plan.current is False
- Lines 1017-1035: legacy cd command full body
- Lines 1314-1315: invariant_actor parameter in use_action
- Lines 1391-1392: building PlanInvariant list from --invariant flags
- Lines 1573-1578: lifecycle-apply auto-discovery with 0 plans
- Lines 1594-1596: lifecycle-apply read-only guard
- Lines 1700-1701: status CleverAgentsError handler
- Branch 1738: errors command when has_error_recovery is False
"""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
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.plan import app as plan_app
from cleveragents.core.exceptions import CleverAgentsError, PlanError
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA"
_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB"
_PATCH_CONTAINER = "cleveragents.application.container.get_container"
_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service"
_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project"
def _make_lifecycle_plan(
*,
plan_id: str = _ULID_A,
name: str = "local/test-plan",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
error_message: str | None = None,
error_details: dict[str, str] | None = None,
read_only: bool = False,
) -> Plan:
"""Build a real Plan domain object for testing."""
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name="local/test-action",
description="Coverage test plan",
definition_of_done=None,
strategy_actor=None,
execution_actor=None,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 0, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
),
error_message=error_message,
error_details=error_details,
reusable=True,
read_only=read_only,
created_by=None,
)
def _make_legacy_project() -> SimpleNamespace:
"""Build a minimal project stub for legacy commands."""
return SimpleNamespace(name="test-project", path="/tmp/test")
def _make_legacy_plan(
*,
name: str = "my-plan",
status: str = "active",
current: bool = True,
) -> SimpleNamespace:
"""Build a minimal legacy plan stub."""
return SimpleNamespace(
name=name,
status=status,
current=current,
created_at=datetime(2025, 1, 1),
prompt="do stuff",
)
# ----------------------------------------------------------------------
# Given — CLI runner
# ----------------------------------------------------------------------
@given("a plan-cov CLI runner")
def step_plan_cov_cli_runner(context: Context) -> None:
"""Initialise a CliRunner and cleanup list."""
context.plan_cov_runner = CliRunner()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
# ----------------------------------------------------------------------
# Given — Legacy apply mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov apply that succeeds")
def step_mock_apply_success(context: Context) -> None:
"""Set up mocks so legacy apply command succeeds through Progress block."""
context.plan_cov_apply_changes_return = 1
context.plan_cov_apply_changes_side_effect = None
@given("a mocked legacy container for plan-cov apply that raises PlanError")
def step_mock_apply_plan_error(context: Context) -> None:
"""Set up mocks so legacy apply raises PlanError during apply_changes."""
context.plan_cov_apply_changes_return = None
context.plan_cov_apply_changes_side_effect = PlanError("Sandbox conflict")
# ----------------------------------------------------------------------
# Given — Legacy list mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov list with non-current plans")
def step_mock_list_non_current(context: Context) -> None:
"""Mark that legacy list should produce plans with current=False."""
context.plan_cov_list_plans = [
_make_legacy_plan(name="alpha", current=False),
_make_legacy_plan(name="beta", current=False),
]
# ----------------------------------------------------------------------
# Given — Legacy cd mocks
# ----------------------------------------------------------------------
@given("a mocked legacy container for plan-cov cd that succeeds")
def step_mock_cd_success(context: Context) -> None:
"""Mark that legacy cd should succeed."""
context.plan_cov_cd_return = _make_legacy_plan(name="my-plan")
context.plan_cov_cd_side_effect = None
@given("a mocked legacy container for plan-cov cd that raises CleverAgentsError")
def step_mock_cd_error(context: Context) -> None:
"""Mark that legacy cd should raise CleverAgentsError."""
context.plan_cov_cd_return = None
context.plan_cov_cd_side_effect = CleverAgentsError("not found")
# ----------------------------------------------------------------------
# Given — Lifecycle service for use_action
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov use action")
def step_mock_lifecycle_for_use(context: Context) -> None:
"""Set up lifecycle service mock for the use command."""
mock_service = MagicMock()
mock_action = MagicMock()
mock_action.namespaced_name = "local/test-action"
mock_service.get_action_by_name.return_value = mock_action
mock_plan = _make_lifecycle_plan()
mock_service.use_action.return_value = mock_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
context.plan_cov_service = mock_service
context.plan_cov_plan = mock_plan
# ----------------------------------------------------------------------
# Given — Lifecycle service for lifecycle-apply
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov that returns no execute-complete plans")
def step_mock_lifecycle_apply_no_plans(context: Context) -> None:
"""Set up lifecycle service that returns no complete plans in Execute phase."""
mock_service = MagicMock()
mock_service.list_plans.return_value = []
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given(
"a mocked lifecycle service for plan-cov that returns multiple execute-complete plans"
)
def step_mock_lifecycle_apply_multiple_plans(context: Context) -> None:
"""Set up lifecycle service that returns multiple complete Execute plans."""
mock_service = MagicMock()
plan_a = _make_lifecycle_plan(
plan_id=_ULID_A,
name="local/plan-a",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
plan_b = _make_lifecycle_plan(
plan_id=_ULID_B,
name="local/plan-b",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
mock_service.list_plans.return_value = [plan_a, plan_b]
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov that returns a read-only plan")
def step_mock_lifecycle_apply_readonly(context: Context) -> None:
"""Set up lifecycle service that returns a read-only plan."""
mock_service = MagicMock()
ro_plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
read_only=True,
)
mock_service.get_plan.return_value = ro_plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for status command error
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov status that raises CleverAgentsError")
def step_mock_lifecycle_status_error(context: Context) -> None:
"""Set up lifecycle service that raises CleverAgentsError on get_plan."""
mock_service = MagicMock()
mock_service.get_plan.side_effect = CleverAgentsError("service unavailable")
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# Given — Lifecycle service for errors command
# ----------------------------------------------------------------------
@given("a mocked lifecycle service for plan-cov errors with no error_category")
def step_mock_lifecycle_errors_no_category(context: Context) -> None:
"""Set up lifecycle service returning a plan with error_details lacking error_category."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.ERRORED,
error_message="Something went wrong",
error_details={"error_phase": "execute"},
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
@given("a mocked lifecycle service for plan-cov errors with empty details")
def step_mock_lifecycle_errors_empty_details(context: Context) -> None:
"""Set up lifecycle service returning a plan with no error_details or error_message."""
mock_service = MagicMock()
plan = _make_lifecycle_plan(
plan_id=_ULID_A,
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.QUEUED,
error_message=None,
error_details=None,
)
mock_service.get_plan.return_value = plan
p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service)
p1.start()
context._cleanup_handlers.append(p1.stop)
# ----------------------------------------------------------------------
# When — Legacy apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy apply with --yes")
def step_invoke_legacy_apply(context: Context) -> None:
"""Invoke the legacy apply command with auto-confirm."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.get_pending_changes.return_value = [
SimpleNamespace(operation="modify", file_path="a.py"),
]
if context.plan_cov_apply_changes_side_effect:
mock_plan_service.apply_changes.side_effect = (
context.plan_cov_apply_changes_side_effect
)
else:
mock_plan_service.apply_changes.return_value = (
context.plan_cov_apply_changes_return
)
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["apply", "--yes"]
)
# ----------------------------------------------------------------------
# When — Legacy list
# ----------------------------------------------------------------------
@when("I invoke plan-cov legacy list")
def step_invoke_legacy_list(context: Context) -> None:
"""Invoke the legacy list command in rich format."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
mock_plan_service.list_plans.return_value = context.plan_cov_list_plans
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["list"])
# ----------------------------------------------------------------------
# When — Legacy cd
# ----------------------------------------------------------------------
@when('I invoke plan-cov legacy cd "{name}"')
def step_invoke_legacy_cd(context: Context, name: str) -> None:
"""Invoke the legacy cd command with a plan name."""
mock_container = MagicMock()
mock_plan_service = MagicMock()
if context.plan_cov_cd_side_effect:
mock_plan_service.switch_to_plan.side_effect = context.plan_cov_cd_side_effect
else:
mock_plan_service.switch_to_plan.return_value = context.plan_cov_cd_return
mock_container.plan_service.return_value = mock_plan_service
project = _make_legacy_project()
with (
patch(_PATCH_CONTAINER, return_value=mock_container),
patch(_PATCH_GET_PROJECT, return_value=project),
):
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["cd", name])
# ----------------------------------------------------------------------
# When — use action with actor overrides
# ----------------------------------------------------------------------
@when('I invoke plan-cov use with estimation-actor "{est}" and invariant-actor "{inv}"')
def step_invoke_use_actor_overrides(context: Context, est: str, inv: str) -> None:
"""Invoke use command with estimation-actor and invariant-actor."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--estimation-actor",
est,
"--invariant-actor",
inv,
],
)
@when('I invoke plan-cov use with invariant "{text}"')
def step_invoke_use_with_invariant(context: Context, text: str) -> None:
"""Invoke use command with --invariant flag."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app,
[
"use",
"local/test-action",
"--project",
"proj-1",
"--invariant",
text,
],
)
# ----------------------------------------------------------------------
# When — lifecycle-apply
# ----------------------------------------------------------------------
@when("I invoke plan-cov lifecycle-apply without plan_id")
def step_invoke_lifecycle_apply_no_id(context: Context) -> None:
"""Invoke lifecycle-apply without specifying a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply"]
)
@when('I invoke plan-cov lifecycle-apply with plan_id "{pid}"')
def step_invoke_lifecycle_apply_with_id(context: Context, pid: str) -> None:
"""Invoke lifecycle-apply with an explicit plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["lifecycle-apply", pid]
)
# ----------------------------------------------------------------------
# When — status
# ----------------------------------------------------------------------
@when('I invoke plan-cov status "{pid}"')
def step_invoke_status(context: Context, pid: str) -> None:
"""Invoke the status command with a plan ID."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["status", pid])
# ----------------------------------------------------------------------
# When — errors
# ----------------------------------------------------------------------
@when('I invoke plan-cov errors "{pid}" with format "{fmt}"')
def step_invoke_errors_json(context: Context, pid: str, fmt: str) -> None:
"""Invoke the errors command with a given format."""
context.plan_cov_result = context.plan_cov_runner.invoke(
plan_app, ["errors", pid, "--format", fmt]
)
@when('I invoke plan-cov errors "{pid}"')
def step_invoke_errors_rich(context: Context, pid: str) -> None:
"""Invoke the errors command in default rich format."""
context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["errors", pid])
# ----------------------------------------------------------------------
# Then — result assertions
# ----------------------------------------------------------------------
@then("the plan-cov command should exit normally")
def step_assert_exit_ok(context: Context) -> None:
"""Assert the CLI exited with code 0."""
assert context.plan_cov_result.exit_code == 0, (
f"Expected exit_code=0, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then("the plan-cov command should abort")
def step_assert_abort(context: Context) -> None:
"""Assert the CLI exited with a non-zero code (abort)."""
assert context.plan_cov_result.exit_code != 0, (
f"Expected non-zero exit_code, got {context.plan_cov_result.exit_code}. "
f"Output: {context.plan_cov_result.output}"
)
@then('the plan-cov output should contain "{text}"')
def step_assert_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the expected text."""
assert text in context.plan_cov_result.output, (
f"Expected output to contain '{text}'. "
f"Actual output: {context.plan_cov_result.output}"
)
@then("the plan-cov created plan should have estimation_actor set")
def step_assert_estimation_actor(context: Context) -> None:
"""Assert the plan was updated with estimation_actor."""
plan = context.plan_cov_plan
assert plan.estimation_actor == "openai/gpt-4", (
f"Expected estimation_actor='openai/gpt-4', got {plan.estimation_actor}"
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,737 @@
"""Step definitions for repositories_coverage.feature.
Targets uncovered lines and branches in ActionRepository and
LifecyclePlanRepository in ``repositories.py``:
Lines: 924, 943-946, 968, 1077-1078, 1124-1127, 1142-1143, 1160,
1162-1163, 1188, 1267, 1283-1284, 1296-1298, 1303, 1315-1319,
1338, 1342-1344, 1383, 1393-1394, 1411-1422
Branches at: 923, 942, 944, 967, 1122, 1159, 1187, 1266, 1282, 1283,
1295, 1337, 1382, 1414, 1416
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
from cleveragents.infrastructure.database.models import (
Base,
LifecyclePlanModel,
)
from cleveragents.infrastructure.database.repositories import (
ActionInUseError,
ActionRepository,
DuplicatePlanError,
LifecyclePlanRepository,
)
# Valid ULIDs for deterministic tests (Crockford base32, 26 chars)
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_ULID_COUNTER = 0
def _next_ulid() -> str:
"""Return a unique, valid ULID string for each call."""
global _ULID_COUNTER
_ULID_COUNTER += 1
n = _ULID_COUNTER
suffix = ""
for _ in range(8):
suffix = _CB32[n % 32] + suffix
n //= 32
return f"01HGZ6FE0AQDYTR4BX{suffix}"
def _make_action(
name: str = "local/test-action",
state: str = "available",
) -> Action:
"""Create a minimal valid Action domain object."""
parts = name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
short_name = parts[1] if len(parts) == 2 else parts[0]
return Action(
namespaced_name=NamespacedName(namespace=namespace, name=short_name),
description=f"Test action {short_name}",
long_description=None,
definition_of_done=f"Verify {short_name} completes",
strategy_actor="local/strategist",
execution_actor="local/executor",
estimation_actor=None,
review_actor=None,
arguments=[],
reusable=True,
read_only=False,
state=ActionState(state),
created_at=datetime.now(),
updated_at=datetime.now(),
created_by=None,
tags=[],
)
def _make_plan(
action_name: str,
plan_name: str = "local/test-plan",
) -> Plan:
"""Create a minimal valid Plan domain object."""
parts = plan_name.split("/", 1)
namespace = parts[0] if len(parts) == 2 else "local"
short_name = parts[1] if len(parts) == 2 else parts[0]
now = datetime.now()
return Plan(
identity=PlanIdentity(plan_id=_next_ulid(), attempt=1),
namespaced_name=NamespacedName(namespace=namespace, name=short_name),
action_name=action_name,
description=f"Test plan {short_name}",
definition_of_done="Verify plan completes",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="local/strategist",
execution_actor="local/executor",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
error_message=None,
error_details=None,
created_by=None,
tags=[],
reusable=True,
read_only=False,
)
# -----------------------------------------------------------------------
# Background
# -----------------------------------------------------------------------
@given("a repos-cov in-memory database with lifecycle schema")
def step_repos_cov_clean_db(context: Context) -> None:
"""Create a fresh in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
context.rc_engine = engine
session = sessionmaker(bind=engine)()
context.rc_session = session
context.rc_session_factory = lambda: session
context.rc_error = None
context.rc_result = None
context.rc_delete_result = None
context.rc_result_list = None
@given("a repos-cov action repository")
def step_repos_cov_action_repo(context: Context) -> None:
"""Instantiate an ActionRepository using the session factory."""
context.rc_action_repo = ActionRepository(
session_factory=context.rc_session_factory,
)
@given("a repos-cov lifecycle plan repository")
def step_repos_cov_plan_repo(context: Context) -> None:
"""Instantiate a LifecyclePlanRepository using the session factory."""
context.rc_plan_repo = LifecyclePlanRepository(
session_factory=context.rc_session_factory,
)
# -----------------------------------------------------------------------
# Helpers for error-producing sessions
# -----------------------------------------------------------------------
class _QueryRaisingSession:
"""Stub session that raises OperationalError on query()."""
def query(self, *args: Any, **kwargs: Any) -> None:
"""Raise OperationalError to simulate a transient DB failure."""
raise OperationalError("simulated", {}, Exception("db down"))
def rollback(self) -> None:
"""No-op rollback."""
def close(self) -> None:
"""No-op close."""
@given(
"a repos-cov action repository with a session that raises OperationalError on query"
)
def step_repos_cov_action_repo_error(context: Context) -> None:
"""Create an ActionRepository whose session raises on query."""
context.rc_action_repo = ActionRepository(
session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type]
)
@given(
"a repos-cov plan repository with a session that raises OperationalError on query"
)
def step_repos_cov_plan_repo_error(context: Context) -> None:
"""Create a LifecyclePlanRepository whose session raises on query."""
context.rc_plan_repo = LifecyclePlanRepository(
session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type]
)
# -----------------------------------------------------------------------
# ActionRepository.get_by_name
# -----------------------------------------------------------------------
@when('repos-cov get_by_name is called with "{name}"')
def step_repos_cov_get_by_name(context: Context, name: str) -> None:
"""Call ActionRepository.get_by_name and capture result or error."""
try:
context.rc_result = context.rc_action_repo.get_by_name(name)
except Exception as exc:
context.rc_error = exc
@then('repos-cov a DatabaseError should be raised containing "{text}"')
def step_repos_cov_verify_db_error(context: Context, text: str) -> None:
"""Assert that a DatabaseError was raised with expected text."""
assert context.rc_error is not None, (
"Expected DatabaseError but no error was raised"
)
assert isinstance(context.rc_error, DatabaseError), (
f"Expected DatabaseError, got {type(context.rc_error).__name__}: {context.rc_error}"
)
assert text in str(context.rc_error), (
f"Expected '{text}' in error message: {context.rc_error}"
)
@then('repos-cov the returned action should have name "{name}"')
def step_repos_cov_verify_action_name(context: Context, name: str) -> None:
"""Assert the returned action has the expected namespaced name."""
assert context.rc_result is not None, "Expected an action, got None"
assert str(context.rc_result.namespaced_name) == name, (
f"Expected '{name}', got '{context.rc_result.namespaced_name}'"
)
# -----------------------------------------------------------------------
# ActionRepository - persisting actions
# -----------------------------------------------------------------------
@given('repos-cov an available action "{name}" exists')
def step_repos_cov_persist_action(context: Context, name: str) -> None:
"""Create and persist an action with default state 'available'."""
action = _make_action(name=name, state="available")
context.rc_action_repo.create(action)
context.rc_session.commit()
context.rc_current_action = action
@given('repos-cov an action "{name}" exists with state "{state}"')
def step_repos_cov_persist_action_state(
context: Context, name: str, state: str
) -> None:
"""Create and persist an action with the given state."""
action = _make_action(name=name, state=state)
context.rc_action_repo.create(action)
context.rc_session.commit()
context.rc_current_action = action
# -----------------------------------------------------------------------
# ActionRepository.get_by_namespace
# -----------------------------------------------------------------------
@when('repos-cov get_by_namespace is called with namespace "{ns}" and state "{state}"')
def step_repos_cov_get_by_namespace_state(
context: Context, ns: str, state: str
) -> None:
"""Call get_by_namespace with namespace and state filter."""
try:
context.rc_result_list = context.rc_action_repo.get_by_namespace(
ns, state=state
)
except Exception as exc:
context.rc_error = exc
@when('repos-cov get_by_namespace is called with namespace "{ns}" expecting error')
def step_repos_cov_get_by_namespace_error(context: Context, ns: str) -> None:
"""Call get_by_namespace expecting a DatabaseError."""
try:
context.rc_action_repo.get_by_namespace(ns)
except Exception as exc:
context.rc_error = exc
@then("repos-cov exactly {count:d} action should be in the result list")
def step_repos_cov_verify_count_singular(context: Context, count: int) -> None:
"""Assert the result list has exactly count items (singular)."""
assert context.rc_result_list is not None, "Result list is None"
assert len(context.rc_result_list) == count, (
f"Expected {count} action(s), got {len(context.rc_result_list)}"
)
@then("repos-cov exactly {count:d} actions should be in the result list")
def step_repos_cov_verify_count_plural(context: Context, count: int) -> None:
"""Assert the result list has exactly count items (plural)."""
assert context.rc_result_list is not None, "Result list is None"
assert len(context.rc_result_list) == count, (
f"Expected {count} action(s), got {len(context.rc_result_list)}"
)
@then('repos-cov the result list should contain action named "{name}"')
def step_repos_cov_verify_list_contains(context: Context, name: str) -> None:
"""Assert the result list contains an action with the given name."""
names = [str(a.namespaced_name) for a in context.rc_result_list]
assert name in names, f"Expected '{name}' in {names}"
# -----------------------------------------------------------------------
# ActionRepository.list_available
# -----------------------------------------------------------------------
@when("repos-cov list_available is called without a namespace filter")
def step_repos_cov_list_available_all(context: Context) -> None:
"""Call list_available with no namespace."""
try:
context.rc_result_list = context.rc_action_repo.list_available()
except Exception as exc:
context.rc_error = exc
@when('repos-cov list_available is called with namespace "{ns}"')
def step_repos_cov_list_available_ns(context: Context, ns: str) -> None:
"""Call list_available with a namespace filter."""
try:
context.rc_result_list = context.rc_action_repo.list_available(namespace=ns)
except Exception as exc:
context.rc_error = exc
@when("repos-cov list_available is called and an error is expected")
def step_repos_cov_list_available_error(context: Context) -> None:
"""Call list_available expecting a DatabaseError."""
try:
context.rc_action_repo.list_available()
except Exception as exc:
context.rc_error = exc
# -----------------------------------------------------------------------
# ActionRepository.update - with min_value/max_value arguments
# -----------------------------------------------------------------------
@given("repos-cov the action has arguments with min_value and max_value set")
def step_repos_cov_add_minmax_args(context: Context) -> None:
"""Add arguments with min_value and max_value to the current action."""
context.rc_current_action.arguments = [
ActionArgument(
name="count",
arg_type=ArgumentType.INTEGER,
requirement=ArgumentRequirement.REQUIRED,
description="Number of items",
default_value=5,
min_value=1.0,
max_value=100.0,
validation_pattern=None,
),
]
@when("repos-cov the action is updated via the repository")
def step_repos_cov_update_action(context: Context) -> None:
"""Update the current action via the repository."""
try:
context.rc_result = context.rc_action_repo.update(context.rc_current_action)
context.rc_session.commit()
except Exception as exc:
context.rc_error = exc
@then("repos-cov the update should succeed")
def step_repos_cov_update_success(context: Context) -> None:
"""Assert no error occurred during update."""
assert context.rc_error is None, f"Unexpected error: {context.rc_error}"
@then("repos-cov the retrieved action arguments should have min_value and max_value")
def step_repos_cov_verify_minmax_args(context: Context) -> None:
"""Retrieve the action and verify min_value/max_value on arguments."""
name = str(context.rc_current_action.namespaced_name)
retrieved = context.rc_action_repo.get_by_id(name)
assert retrieved is not None, f"Action '{name}' not found after update"
assert len(retrieved.arguments) >= 1, "Expected at least 1 argument"
arg = retrieved.arguments[0]
assert arg.min_value == 1.0, f"Expected min_value=1.0, got {arg.min_value}"
assert arg.max_value == 100.0, f"Expected max_value=100.0, got {arg.max_value}"
# -----------------------------------------------------------------------
# ActionRepository.delete
# -----------------------------------------------------------------------
@given('a repos-cov lifecycle plan references action "{action_name}"')
def step_repos_cov_create_referencing_plan(context: Context, action_name: str) -> None:
"""Insert a LifecyclePlanModel row referencing the specified action."""
session: Session = context.rc_session
now_iso = datetime.now().isoformat()
plan_model = LifecyclePlanModel(
plan_id=_next_ulid(),
action_name=action_name,
phase="strategize",
processing_state="queued",
attempt=1,
namespaced_name="local/ref-plan",
namespace="local",
description="Plan referencing action under test",
created_at=now_iso,
updated_at=now_iso,
tags_json="[]",
)
session.add(plan_model)
session.flush()
session.commit()
@when('repos-cov delete is called for action "{name}"')
def step_repos_cov_delete_action(context: Context, name: str) -> None:
"""Call ActionRepository.delete and capture result or error."""
try:
context.rc_delete_result = context.rc_action_repo.delete(name)
context.rc_session.commit()
except Exception as exc:
context.rc_error = exc
@then('repos-cov an ActionInUseError should be raised for "{name}"')
def step_repos_cov_verify_action_in_use(context: Context, name: str) -> None:
"""Assert that an ActionInUseError was raised."""
assert context.rc_error is not None, (
"Expected ActionInUseError but no error was raised"
)
assert isinstance(context.rc_error, ActionInUseError), (
f"Expected ActionInUseError, got {type(context.rc_error).__name__}: {context.rc_error}"
)
assert name in str(context.rc_error), (
f"Expected '{name}' in error message: {context.rc_error}"
)
@then("repos-cov the delete result should be True")
def step_repos_cov_delete_true(context: Context) -> None:
"""Assert delete returned True."""
assert context.rc_delete_result is True, (
f"Expected True, got {context.rc_delete_result}"
)
@then("repos-cov the delete result should be False")
def step_repos_cov_delete_false(context: Context) -> None:
"""Assert delete returned False."""
assert context.rc_delete_result is False, (
f"Expected False, got {context.rc_delete_result}"
)
@then('repos-cov get_by_id for "{name}" should return None')
def step_repos_cov_verify_deleted(context: Context, name: str) -> None:
"""Assert the action no longer exists."""
found = context.rc_action_repo.get_by_id(name)
assert found is None, f"Expected None, got {found}"
# -----------------------------------------------------------------------
# DuplicatePlanError
# -----------------------------------------------------------------------
@when('repos-cov a DuplicatePlanError is created for plan_id "{plan_id}"')
def step_repos_cov_create_dup_plan_error(context: Context, plan_id: str) -> None:
"""Instantiate a DuplicatePlanError."""
context.rc_dup_plan_error = DuplicatePlanError(plan_id)
@then('repos-cov the error message should contain "{text}"')
def step_repos_cov_verify_error_contains(context: Context, text: str) -> None:
"""Assert the error message contains expected text."""
assert text in str(context.rc_dup_plan_error), (
f"Expected '{text}' in '{context.rc_dup_plan_error}'"
)
@then('repos-cov the error plan_id attribute should be "{plan_id}"')
def step_repos_cov_verify_plan_id_attr(context: Context, plan_id: str) -> None:
"""Assert the plan_id attribute is correct."""
assert context.rc_dup_plan_error.plan_id == plan_id, (
f"Expected plan_id='{plan_id}', got '{context.rc_dup_plan_error.plan_id}'"
)
# -----------------------------------------------------------------------
# LifecyclePlanRepository.get_by_name
# -----------------------------------------------------------------------
@given(
'a repos-cov lifecycle plan linked to "{action_name}" with name "{plan_name}" has been persisted'
)
def step_repos_cov_persist_plan(
context: Context, action_name: str, plan_name: str
) -> None:
"""Create and persist a lifecycle plan."""
plan = _make_plan(action_name=action_name, plan_name=plan_name)
context.rc_plan_repo.create(plan)
context.rc_session.commit()
context.rc_current_plan = plan
@when('repos-cov plan get_by_name is called with "{name}"')
def step_repos_cov_plan_get_by_name(context: Context, name: str) -> None:
"""Call LifecyclePlanRepository.get_by_name and capture result or error."""
try:
context.rc_result = context.rc_plan_repo.get_by_name(name)
except Exception as exc:
context.rc_error = exc
@when('repos-cov plan get_by_name is called with "{name}" expecting error')
def step_repos_cov_plan_get_by_name_error(context: Context, name: str) -> None:
"""Call LifecyclePlanRepository.get_by_name expecting error."""
try:
context.rc_plan_repo.get_by_name(name)
except Exception as exc:
context.rc_error = exc
@then("repos-cov the returned plan should not be None")
def step_repos_cov_plan_not_none(context: Context) -> None:
"""Assert the returned plan is not None."""
assert context.rc_result is not None, "Expected a plan, got None"
@then("repos-cov the returned plan should be None")
def step_repos_cov_plan_none(context: Context) -> None:
"""Assert the returned plan is None."""
assert context.rc_result is None, f"Expected None, got {context.rc_result}"
# -----------------------------------------------------------------------
# LifecyclePlanRepository.update - with project_links, arguments, invariants
# -----------------------------------------------------------------------
class _StubInvariantWithText:
"""Stub invariant with .text and .source attributes (source has .value)."""
def __init__(self, text: str, source: InvariantSource) -> None:
"""Initialise with text and InvariantSource enum."""
self.text = text
self.source = source
class _StubInvariantPlainString:
"""Stub invariant that is a plain string (no .text, no .source)."""
def __init__(self, text: str) -> None:
"""Initialise with just a string value."""
self._text = text
def __str__(self) -> str:
"""Return the text representation."""
return self._text
@given(
"repos-cov the plan has project_links, arguments, and invariants with text attribute"
)
def step_repos_cov_plan_add_children(context: Context) -> None:
"""Add project_links, arguments, and invariants (with .text) to the plan."""
plan = context.rc_current_plan
plan.project_links = [
ProjectLink(project_name="local/my-project", alias=None, read_only=False),
]
plan.arguments = {"greeting": "hello", "count": 42}
plan.arguments_order = ["greeting", "count"]
plan.invariants = [
PlanInvariant(text="Must not break prod", source=InvariantSource.PLAN),
]
plan.error_message = None
plan.error_details = None
plan.changeset_id = None
@when("repos-cov the plan is updated via the repository")
def step_repos_cov_update_plan(context: Context) -> None:
"""Update the current plan via the repository."""
try:
context.rc_result = context.rc_plan_repo.update(context.rc_current_plan)
context.rc_session.commit()
except Exception as exc:
context.rc_error = exc
@then("repos-cov the plan update should succeed")
def step_repos_cov_plan_update_success(context: Context) -> None:
"""Assert no error occurred during plan update."""
assert context.rc_error is None, f"Unexpected error: {context.rc_error}"
@then("repos-cov the retrieved plan should have project links")
def step_repos_cov_verify_project_links(context: Context) -> None:
"""Retrieve the plan and verify project links."""
plan_id = context.rc_current_plan.identity.plan_id
retrieved = context.rc_plan_repo.get(plan_id)
assert retrieved is not None, f"Plan '{plan_id}' not found after update"
assert len(retrieved.project_links) >= 1, (
f"Expected at least 1 project link, got {len(retrieved.project_links)}"
)
assert retrieved.project_links[0].project_name == "local/my-project", (
f"Expected project_name='local/my-project', got '{retrieved.project_links[0].project_name}'"
)
@then("repos-cov the retrieved plan should have arguments")
def step_repos_cov_verify_plan_args(context: Context) -> None:
"""Retrieve the plan and verify arguments."""
plan_id = context.rc_current_plan.identity.plan_id
retrieved = context.rc_plan_repo.get(plan_id)
assert retrieved is not None, f"Plan '{plan_id}' not found after update"
assert len(retrieved.arguments) >= 1, (
f"Expected at least 1 argument, got {len(retrieved.arguments)}"
)
@then("repos-cov the retrieved plan should have invariants")
def step_repos_cov_verify_plan_invariants(context: Context) -> None:
"""Retrieve the plan and verify invariants."""
plan_id = context.rc_current_plan.identity.plan_id
retrieved = context.rc_plan_repo.get(plan_id)
assert retrieved is not None, f"Plan '{plan_id}' not found after update"
assert len(retrieved.invariants) >= 1, (
f"Expected at least 1 invariant, got {len(retrieved.invariants)}"
)
# -----------------------------------------------------------------------
# LifecyclePlanRepository.update - invariants as plain strings
# -----------------------------------------------------------------------
@given("repos-cov the plan has invariants as plain strings without text attribute")
def step_repos_cov_plan_plain_string_invariants(context: Context) -> None:
"""Set invariants on the plan as objects without .text attribute (falls back to str)."""
plan = context.rc_current_plan
plan.project_links = []
plan.arguments = {}
plan.arguments_order = []
# Use object.__setattr__ to bypass pydantic validation — the code path does:
# inv_text = inv.text if hasattr(inv, "text") else str(inv)
object.__setattr__(
plan,
"invariants",
[_StubInvariantPlainString("No side effects allowed")],
)
# -----------------------------------------------------------------------
# LifecyclePlanRepository.update - invariant source with .value enum
# -----------------------------------------------------------------------
@given("repos-cov the plan has invariants with InvariantSource enum values")
def step_repos_cov_plan_enum_invariants(context: Context) -> None:
"""Set invariants using stub objects with InvariantSource enum source."""
plan = context.rc_current_plan
plan.project_links = []
plan.arguments = {}
plan.arguments_order = []
# Use object.__setattr__ to bypass pydantic validation
# _StubInvariantWithText has .text and .source (an InvariantSource enum with .value)
object.__setattr__(
plan,
"invariants",
[
_StubInvariantWithText(
"Keep API backward compatible", InvariantSource.ACTION
)
],
)
# -----------------------------------------------------------------------
# LifecyclePlanRepository.update - processing_state as plain string
# -----------------------------------------------------------------------
@given('repos-cov the plan processing_state is set to a plain string "{state}"')
def step_repos_cov_plan_plain_state(context: Context, state: str) -> None:
"""Set the processing_state to a plain string (no .value attribute)."""
plan = context.rc_current_plan
plan.project_links = []
plan.arguments = {}
plan.arguments_order = []
plan.invariants = []
# Use object.__setattr__ to bypass pydantic validation and set a raw string
# that doesn't have a .value attribute — exercises the else branch at line 1317
object.__setattr__(plan, "processing_state", state)
# -----------------------------------------------------------------------
# LifecyclePlanRepository.update - error_details and changeset_id
# -----------------------------------------------------------------------
@given("repos-cov the plan has error_message, error_details, and changeset_id set")
def step_repos_cov_plan_error_details(context: Context) -> None:
"""Set error_message, error_details, and changeset_id on the plan."""
plan = context.rc_current_plan
plan.project_links = []
plan.arguments = {}
plan.arguments_order = []
plan.invariants = []
plan.error_message = "Something went wrong"
plan.error_details = {"step": "execute", "reason": "timeout"}
plan.changeset_id = "01HGZ6FE0AQDYTR4BX00000001"
@then("repos-cov the retrieved plan should have the error_message set")
def step_repos_cov_verify_error_message(context: Context) -> None:
"""Retrieve the plan and verify error_message is set."""
plan_id = context.rc_current_plan.identity.plan_id
retrieved = context.rc_plan_repo.get(plan_id)
assert retrieved is not None, f"Plan '{plan_id}' not found after update"
assert retrieved.error_message == "Something went wrong", (
f"Expected error_message='Something went wrong', got '{retrieved.error_message}'"
)
@@ -0,0 +1,428 @@
"""Step definitions for CopyOnWriteSandbox uncovered lines and branches.
Targets every gap identified in the coverage report for
``copy_on_write.py`` (line-rate=0.9124, branch-rate=0.8519):
- Lines 143-145: ``create()`` OSError handler wrapping into SandboxCreationError
- Line 196 branch False199: ``get_path()`` when already ACTIVE (skip CREATED transition)
- Line 200: ``get_path()`` raises when ``_sandbox_path`` is ``None``
- Line 230: ``commit()`` raises when ``_sandbox_path`` is ``None``
- Line 242 branch False244: ``commit()`` skips ``os.makedirs`` when ``dst_dir`` is empty
- Line 249 branch False247: ``commit()`` deleted file already missing from original
- Lines 252-254: ``commit()`` OSError handler wrapping into SandboxCommitError
- Line 297: ``rollback()`` raises when ``_sandbox_path`` is ``None``
- Lines 311-313: ``rollback()`` OSError handler wrapping into SandboxRollbackError
- Line 341 branch False347: ``cleanup()`` when ``_sandbox_path`` is ``None``
- Line 344 branch False347: ``cleanup()`` when parent dir already removed
All steps use the ``cowcov`` prefix to avoid collisions with existing
``cow`` and ``cowcb`` step prefixes.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
from cleveragents.infrastructure.sandbox.protocol import (
SandboxCommitError,
SandboxCreationError,
SandboxRollbackError,
SandboxStateError,
SandboxStatus,
)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _cowcov_make_test_dir() -> str:
"""Create a temporary directory with seed files for testing."""
d = tempfile.mkdtemp(prefix="cowcov-test-dir-")
with open(os.path.join(d, "existing.txt"), "w") as f:
f.write("original content")
with open(os.path.join(d, "to_delete.txt"), "w") as f:
f.write("will be deleted")
os.makedirs(os.path.join(d, "sub"), exist_ok=True)
with open(os.path.join(d, "sub", "nested.txt"), "w") as f:
f.write("nested")
return d
def _cowcov_cleanup_dir(path: str) -> None:
"""Remove a directory tree if it still exists."""
if path and os.path.exists(path):
shutil.rmtree(path, ignore_errors=True)
# ----------------------------------------------------------------------
# Given
# ----------------------------------------------------------------------
@given("a cowcov test directory is initialised")
def step_cowcov_init_dir(context: Context) -> None:
"""Create a fresh temporary directory with seed files."""
context.cowcov_test_dir = _cowcov_make_test_dir()
context.cowcov_sandbox = None
context.cowcov_error = None
context.cowcov_commit_result = None
context.cowcov_resolved_paths = []
context.cowcov_makedirs_mock = None
context._cleanup_handlers.append(
lambda: _cowcov_cleanup_dir(context.cowcov_test_dir)
)
@given("shutil.copytree is patched to raise OSError in cowcov")
def step_cowcov_patch_copytree_oserror(context: Context) -> None:
"""Patch shutil.copytree in the copy_on_write module to raise OSError."""
patcher = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree",
side_effect=OSError("mocked copytree failure"),
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given('a cowcov sandbox is created for plan "{plan_id}"')
def step_cowcov_create_sandbox(context: Context, plan_id: str) -> None:
"""Create a CopyOnWriteSandbox and call create()."""
context.cowcov_sandbox = CopyOnWriteSandbox(
resource_id="res-cowcov",
original_path=context.cowcov_test_dir,
)
context.cowcov_sandbox.create(plan_id)
sandbox_path = context.cowcov_sandbox._sandbox_path
if sandbox_path:
parent = os.path.dirname(sandbox_path)
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent))
@given('a cowcov sandbox is created and activated for plan "{plan_id}"')
def step_cowcov_create_and_activate(context: Context, plan_id: str) -> None:
"""Create a sandbox and transition to ACTIVE via get_path."""
context.cowcov_sandbox = CopyOnWriteSandbox(
resource_id="res-cowcov",
original_path=context.cowcov_test_dir,
)
context.cowcov_sandbox.create(plan_id)
context.cowcov_sandbox.get_path("existing.txt")
assert context.cowcov_sandbox.status == SandboxStatus.ACTIVE, (
f"Expected ACTIVE, got {context.cowcov_sandbox.status}"
)
sandbox_path = context.cowcov_sandbox._sandbox_path
if sandbox_path:
parent = os.path.dirname(sandbox_path)
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent))
@given("a cowcov sandbox forced to ACTIVE with sandbox_path None")
def step_cowcov_force_active_none(context: Context) -> None:
"""Create a sandbox stub with status=ACTIVE and _sandbox_path=None."""
d = _cowcov_make_test_dir()
context.cowcov_test_dir = d
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
sandbox._status = SandboxStatus.ACTIVE
sandbox._sandbox_path = None
context.cowcov_sandbox = sandbox
context.cowcov_error = None
context.cowcov_commit_result = None
context.cowcov_resolved_paths = []
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
@given("a cowcov sandbox forced to CREATED with sandbox_path None")
def step_cowcov_force_created_none(context: Context) -> None:
"""Create a sandbox stub with status=CREATED and _sandbox_path=None."""
d = _cowcov_make_test_dir()
context.cowcov_test_dir = d
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
sandbox._status = SandboxStatus.CREATED
sandbox._sandbox_path = None
context.cowcov_sandbox = sandbox
context.cowcov_error = None
context.cowcov_commit_result = None
context.cowcov_resolved_paths = []
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
@given("a cowcov sandbox in PENDING state with sandbox_path None")
def step_cowcov_pending_none(context: Context) -> None:
"""Create a sandbox that is still PENDING (never created)."""
d = _cowcov_make_test_dir()
context.cowcov_test_dir = d
sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d)
assert sandbox._sandbox_path is None, "Expected _sandbox_path to be None"
assert sandbox.status == SandboxStatus.PENDING, "Expected PENDING status"
context.cowcov_sandbox = sandbox
context.cowcov_error = None
context.cowcov_commit_result = None
context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d))
@given("cowcov _compute_diff is patched to return a changed root file")
def step_cowcov_patch_compute_diff(context: Context) -> None:
"""Patch _compute_diff to return one changed file at root level."""
patcher = patch.object(
CopyOnWriteSandbox,
"_compute_diff",
return_value=(["rootfile.txt"], [], []),
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("cowcov os.path.dirname is patched to return empty string")
def step_cowcov_patch_dirname(context: Context) -> None:
"""Patch os.path.dirname in the copy_on_write module to return ''."""
patcher = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.os.path.dirname",
return_value="",
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("cowcov shutil.copy2 is patched to no-op")
def step_cowcov_patch_copy2_noop(context: Context) -> None:
"""Patch shutil.copy2 in the copy_on_write module to do nothing."""
makedirs_mock = MagicMock()
patcher_copy2 = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2",
)
patcher_makedirs = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.os.makedirs",
makedirs_mock,
)
patcher_copy2.start()
patcher_makedirs.start()
context.cowcov_makedirs_mock = makedirs_mock
context._cleanup_handlers.append(patcher_copy2.stop)
context._cleanup_handlers.append(patcher_makedirs.stop)
@given('a cowcov file "existing.txt" is modified in the sandbox')
def step_cowcov_modify_existing(context: Context) -> None:
"""Modify a file inside the sandbox copy."""
sandbox_path = context.cowcov_sandbox._sandbox_path
assert sandbox_path is not None, "Sandbox path must be set"
fpath = os.path.join(sandbox_path, "existing.txt")
with open(fpath, "w") as f:
f.write("modified for OSError test")
@given("cowcov shutil.copy2 is patched to raise OSError")
def step_cowcov_patch_copy2_oserror(context: Context) -> None:
"""Patch shutil.copy2 in copy_on_write module to raise OSError."""
patcher = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2",
side_effect=OSError("mocked copy2 failure"),
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("cowcov shutil.copytree is patched to raise OSError for rollback")
def step_cowcov_patch_copytree_rollback(context: Context) -> None:
"""Patch shutil.copytree to raise OSError (for rollback re-copy)."""
patcher = patch(
"cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree",
side_effect=OSError("mocked copytree failure during rollback"),
)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
@given("the cowcov sandbox parent directory is manually removed")
def step_cowcov_remove_parent(context: Context) -> None:
"""Remove the sandbox parent temp dir before cleanup."""
sandbox_path = context.cowcov_sandbox._sandbox_path
assert sandbox_path is not None, "Sandbox path must be set"
parent = os.path.dirname(sandbox_path)
if os.path.exists(parent):
shutil.rmtree(parent, ignore_errors=True)
# ----------------------------------------------------------------------
# When
# ----------------------------------------------------------------------
@when('a cowcov sandbox create is attempted for plan "{plan_id}"')
def step_cowcov_attempt_create(context: Context, plan_id: str) -> None:
"""Attempt to create a sandbox, capturing any error."""
context.cowcov_sandbox = CopyOnWriteSandbox(
resource_id="res-cowcov",
original_path=context.cowcov_test_dir,
)
try:
context.cowcov_sandbox.create(plan_id)
except (SandboxCreationError, SandboxStateError) as exc:
context.cowcov_error = exc
@when('cowcov get_path is called with "{path}"')
def step_cowcov_get_path(context: Context, path: str) -> None:
"""Call get_path and record the resolved path."""
resolved = context.cowcov_sandbox.get_path(path)
context.cowcov_resolved_paths.append(resolved)
@when('cowcov get_path is attempted with "{path}"')
def step_cowcov_attempt_get_path(context: Context, path: str) -> None:
"""Attempt get_path, capturing any error."""
try:
context.cowcov_sandbox.get_path(path)
except (SandboxStateError, ValueError) as exc:
context.cowcov_error = exc
@when("cowcov commit is attempted")
def step_cowcov_attempt_commit(context: Context) -> None:
"""Attempt commit, capturing any error."""
try:
context.cowcov_commit_result = context.cowcov_sandbox.commit()
except (SandboxStateError, SandboxCommitError) as exc:
context.cowcov_error = exc
@when('the cowcov file "existing.txt" is deleted from both sandbox and original')
def step_cowcov_delete_from_both(context: Context) -> None:
"""Delete a file from both sandbox copy and original directory."""
sandbox_path = context.cowcov_sandbox._sandbox_path
assert sandbox_path is not None, "Sandbox path must be set"
for base in (sandbox_path, context.cowcov_test_dir):
fpath = os.path.join(base, "existing.txt")
if os.path.exists(fpath):
os.remove(fpath)
@when("cowcov rollback is attempted")
def step_cowcov_attempt_rollback(context: Context) -> None:
"""Attempt rollback, capturing any error."""
try:
context.cowcov_sandbox.rollback()
except (SandboxStateError, SandboxRollbackError) as exc:
context.cowcov_error = exc
@when("cowcov cleanup is called")
def step_cowcov_cleanup(context: Context) -> None:
"""Call cleanup on the sandbox."""
context.cowcov_sandbox.cleanup()
# ----------------------------------------------------------------------
# Then
# ----------------------------------------------------------------------
@then("a cowcov SandboxCreationError should be raised")
def step_cowcov_check_creation_error(context: Context) -> None:
"""Assert a SandboxCreationError was captured."""
assert context.cowcov_error is not None, (
"Expected SandboxCreationError but no error occurred"
)
assert isinstance(context.cowcov_error, SandboxCreationError), (
f"Expected SandboxCreationError, got {type(context.cowcov_error).__name__}: "
f"{context.cowcov_error}"
)
@then('the cowcov error message should contain "{fragment}"')
def step_cowcov_error_message_contains(context: Context, fragment: str) -> None:
"""Assert the error message contains the given fragment."""
assert context.cowcov_error is not None, "No error was captured"
assert fragment in str(context.cowcov_error), (
f"Expected '{fragment}' in error message, got: {context.cowcov_error}"
)
@then('the cowcov sandbox status should be "{status}"')
def step_cowcov_check_status(context: Context, status: str) -> None:
"""Assert the sandbox status matches the expected value."""
expected = SandboxStatus(status)
assert context.cowcov_sandbox.status == expected, (
f"Expected status {expected.value}, got {context.cowcov_sandbox.status.value}"
)
@then("both cowcov resolved paths should be valid")
def step_cowcov_two_paths_valid(context: Context) -> None:
"""Assert two paths were resolved and both are inside the sandbox."""
assert len(context.cowcov_resolved_paths) == 2, (
f"Expected 2 resolved paths, got {len(context.cowcov_resolved_paths)}"
)
sandbox_path = context.cowcov_sandbox._sandbox_path
assert sandbox_path is not None, "Sandbox path should be set"
for p in context.cowcov_resolved_paths:
assert p.startswith(sandbox_path), (
f"Resolved path {p} is not inside sandbox {sandbox_path}"
)
@then('a cowcov SandboxStateError should be raised with message "{msg}"')
def step_cowcov_check_state_error_msg(context: Context, msg: str) -> None:
"""Assert a SandboxStateError with the expected message was captured."""
assert context.cowcov_error is not None, (
"Expected SandboxStateError but no error occurred"
)
assert isinstance(context.cowcov_error, SandboxStateError), (
f"Expected SandboxStateError, got {type(context.cowcov_error).__name__}: "
f"{context.cowcov_error}"
)
assert msg in str(context.cowcov_error), (
f"Expected '{msg}' in error message, got: {context.cowcov_error}"
)
@then("the cowcov commit should succeed")
def step_cowcov_commit_success(context: Context) -> None:
"""Assert commit completed without error."""
assert context.cowcov_error is None, (
f"Expected no error but got: {context.cowcov_error}"
)
assert context.cowcov_commit_result is not None, "Expected a commit result"
assert context.cowcov_commit_result.success is True, (
"Expected commit result success=True"
)
@then("cowcov os.makedirs should not have been called")
def step_cowcov_makedirs_not_called(context: Context) -> None:
"""Assert os.makedirs was not invoked (dst_dir was empty)."""
assert context.cowcov_makedirs_mock is not None, "makedirs mock not set up"
context.cowcov_makedirs_mock.assert_not_called()
@then("a cowcov SandboxCommitError should be raised")
def step_cowcov_check_commit_error(context: Context) -> None:
"""Assert a SandboxCommitError was captured."""
assert context.cowcov_error is not None, (
"Expected SandboxCommitError but no error occurred"
)
assert isinstance(context.cowcov_error, SandboxCommitError), (
f"Expected SandboxCommitError, got {type(context.cowcov_error).__name__}: "
f"{context.cowcov_error}"
)
@then("a cowcov SandboxRollbackError should be raised")
def step_cowcov_check_rollback_error(context: Context) -> None:
"""Assert a SandboxRollbackError was captured."""
assert context.cowcov_error is not None, (
"Expected SandboxRollbackError but no error occurred"
)
assert isinstance(context.cowcov_error, SandboxRollbackError), (
f"Expected SandboxRollbackError, got {type(context.cowcov_error).__name__}: "
f"{context.cowcov_error}"
)
@@ -0,0 +1,603 @@
# 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 = []