forked from cleveragents/cleveragents-core
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:
@@ -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 39→38, 67→68, 182→184, 260→258
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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 False→199: ``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 False→244: ``commit()`` skips ``os.makedirs`` when ``dst_dir`` is empty
|
||||
- Line 249 branch False→247: ``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 False→347: ``cleanup()`` when ``_sandbox_path`` is ``None``
|
||||
- Line 344 branch False→347: ``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 = []
|
||||
Reference in New Issue
Block a user