Files
cleveragents-core/features/steps/action_schema_steps.py
T
aditya 8da7bd1e56
CI / lint (pull_request) Successful in 16s
CI / typecheck (pull_request) Successful in 26s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 4m17s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 7m45s
CI / docker (pull_request) Successful in 44s
CI / coverage (pull_request) Successful in 5m38s
docs(action): add action YAML schema and examples
2026-02-13 19:01:55 +05:30

459 lines
17 KiB
Python

"""Step definitions for action YAML schema validation.
Tests for features/action_schema.feature — validates the ActionConfigSchema
Pydantic model, YAML loading, key normalization, invariant normalization,
environment variable interpolation, and error messages.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.action.schema import ActionConfigSchema
# ────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────
_MINIMAL_YAML = """\
name: local/simple-action
description: A simple action for testing
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: All tests pass
"""
_FULL_YAML = """\
schema_version: "1"
name: local/full-action
description: A fully populated action
long_description: |
This action demonstrates all optional fields.
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
estimation_actor: local/estimator
review_actor: local/reviewer
apply_actor: local/applier
invariant_actor: local/invariant-resolver
definition_of_done: All tests pass and coverage is above 97%
reusable: true
read_only: false
state: available
automation_profile: local/cautious
invariants:
- "No secrets in code"
- "All tests must pass"
arguments:
- name: target
type: integer
required: true
description: Target coverage percentage
min_value: 1
max_value: 100
inputs_schema:
type: object
properties:
config:
type: string
"""
_ARGUMENTS_YAML = """\
name: local/with-args
description: Action with arguments
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Coverage target reached
arguments:
- name: target_coverage
type: integer
required: true
description: Target coverage percentage
min_value: 1
max_value: 100
- name: test_command
type: string
required: false
description: Test framework command
default: "pytest --cov"
"""
def _make_yaml_missing(field: str) -> str:
"""Return minimal YAML with one required field removed."""
lines = _MINIMAL_YAML.strip().splitlines()
return "\n".join(ln for ln in lines if not ln.startswith(f"{field}:"))
# ────────────────────────────────────────────────────────────
# Given steps
# ────────────────────────────────────────────────────────────
@given("an action YAML string with only required fields")
def step_given_minimal_yaml(context: Context) -> None:
"""Provide a minimal valid action YAML string."""
context.action_yaml_string = _MINIMAL_YAML
@given("an action YAML string with all optional fields populated")
def step_given_full_yaml(context: Context) -> None:
"""Provide a fully populated action YAML string."""
context.action_yaml_string = _FULL_YAML
@given("an action YAML string with typed arguments")
def step_given_arguments_yaml(context: Context) -> None:
"""Provide an action YAML with typed arguments."""
context.action_yaml_string = _ARGUMENTS_YAML
@given('the action YAML file "{rel_path}"')
def step_given_action_yaml_file(context: Context, rel_path: str) -> None:
"""Set the path to an action YAML file relative to project root."""
project_root = Path(__file__).resolve().parents[2]
context.action_yaml_file = str(project_root / rel_path)
@given('an action YAML string missing the "{field}" field')
def step_given_yaml_missing_field(context: Context, field: str) -> None:
"""Provide YAML with a specific required field removed."""
context.action_yaml_string = _make_yaml_missing(field)
@given('an action YAML string with name "{name}"')
def step_given_yaml_with_name(context: Context, name: str) -> None:
"""Provide YAML with a custom name value."""
context.action_yaml_string = _MINIMAL_YAML.replace("local/simple-action", name)
@given('an action YAML string with an argument of type "{arg_type}"')
def step_given_yaml_with_bad_arg_type(context: Context, arg_type: str) -> None:
"""Provide YAML with an invalid argument type."""
context.action_yaml_string = f"""\
name: local/bad-arg
description: Action with bad argument type
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Done
arguments:
- name: bad_arg
type: {arg_type}
required: true
"""
@given('an action YAML string with state "{state}"')
def step_given_yaml_with_state(context: Context, state: str) -> None:
"""Provide YAML with a specific state value."""
context.action_yaml_string = _MINIMAL_YAML.rstrip() + f"\nstate: {state}\n"
@given('an action YAML string with an argument named "{name}"')
def step_given_yaml_with_bad_arg_name(context: Context, name: str) -> None:
"""Provide YAML with an invalid argument name."""
context.action_yaml_string = f"""\
name: local/bad-argname
description: Action with bad argument name
strategy_actor: openai/gpt-4
execution_actor: openai/gpt-4
definition_of_done: Done
arguments:
- name: "{name}"
type: string
required: true
"""
@given("a None action YAML string")
def step_given_none_yaml(context: Context) -> None:
"""Set YAML string to None for None guard testing."""
context.action_yaml_string = None # type: ignore[assignment]
@given("an empty action YAML string")
def step_given_empty_yaml(context: Context) -> None:
"""Set YAML string to empty for empty guard testing."""
context.action_yaml_string = ""
@given("an action YAML string that is a list")
def step_given_yaml_list(context: Context) -> None:
"""Set YAML string to a list instead of a mapping."""
context.action_yaml_string = "- item1\n- item2\n"
@given("a None action YAML file path")
def step_given_none_file_path(context: Context) -> None:
"""Set file path to None for None guard testing."""
context.action_yaml_file = None # type: ignore[assignment]
@given('the action YAML directory path "{dir_path}"')
def step_given_yaml_directory_path(context: Context, dir_path: str) -> None:
"""Set file path to a directory for directory guard testing."""
project_root = Path(__file__).resolve().parents[2]
context.action_yaml_file = str(project_root / dir_path)
@given("an action YAML string with empty invariant strings")
def step_given_yaml_with_empty_invariants(context: Context) -> None:
"""Provide YAML with invariants that are empty or blank."""
context.action_yaml_string = (
_MINIMAL_YAML.rstrip() + '\ninvariants:\n - ""\n - " "\n'
)
@given("an action YAML string with duplicate invariants")
def step_given_yaml_with_duplicate_invariants(context: Context) -> None:
"""Provide YAML with duplicate invariant entries."""
context.action_yaml_string = (
_MINIMAL_YAML.rstrip()
+ '\ninvariants:\n - "No secrets in code"\n - "All tests must pass"\n - "No secrets in code"\n'
)
@given("an action YAML string with whitespace-padded invariants")
def step_given_yaml_with_whitespace_invariants(context: Context) -> None:
"""Provide YAML with invariants that have leading/trailing whitespace."""
context.action_yaml_string = (
_MINIMAL_YAML.rstrip() + '\ninvariants:\n - " Keep it clean "\n'
)
@given("an action YAML string with camelCase keys")
def step_given_yaml_with_camel_case(context: Context) -> None:
"""Provide YAML using camelCase key names."""
context.action_yaml_string = """\
name: local/camel-action
description: Action with camelCase keys
strategyActor: openai/gpt-4
executionActor: openai/gpt-4
definitionOfDone: All tests pass
"""
@given('an action YAML string using env var "${{{var}}}" for strategy_actor')
def step_given_yaml_with_env_var(context: Context, var: str) -> None:
"""Provide YAML with environment variable reference."""
context.action_yaml_string = f"""\
name: local/env-action
description: Action using env vars
strategy_actor: ${{{var}}}
execution_actor: openai/gpt-4
definition_of_done: All tests pass
"""
# ────────────────────────────────────────────────────────────
# When steps
# ────────────────────────────────────────────────────────────
@when("I validate the action schema")
def step_when_validate_action_schema(context: Context) -> None:
"""Validate the action YAML string via ActionConfigSchema."""
try:
context.action_config = ActionConfigSchema.from_yaml(context.action_yaml_string)
context.action_schema_error = None
except (ValidationError, ValueError) as exc:
context.action_config = None
context.action_schema_error = str(exc)
@when("I validate the action schema from file")
def step_when_validate_from_file(context: Context) -> None:
"""Validate an action YAML file via ActionConfigSchema."""
try:
context.action_config = ActionConfigSchema.from_yaml_file(
context.action_yaml_file
)
context.action_schema_error = None
except (ValidationError, ValueError, FileNotFoundError) as exc:
context.action_config = None
context.action_schema_error = str(exc)
@when("I validate the action schema expecting failure")
def step_when_validate_expecting_failure(context: Context) -> None:
"""Validate the action YAML, expecting it to fail."""
try:
context.action_config = ActionConfigSchema.from_yaml(context.action_yaml_string)
context.action_schema_error = None
except (ValidationError, ValueError) as exc:
context.action_config = None
context.action_schema_error = str(exc)
@when("I validate the action schema from file expecting failure")
def step_when_validate_file_expecting_failure(context: Context) -> None:
"""Validate an action YAML file, expecting it to fail."""
try:
context.action_config = ActionConfigSchema.from_yaml_file(
context.action_yaml_file
)
context.action_schema_error = None
except (ValidationError, ValueError, FileNotFoundError, TypeError) as exc:
context.action_config = None
context.action_schema_error = str(exc)
# ────────────────────────────────────────────────────────────
# Then steps
# ────────────────────────────────────────────────────────────
@then("the action schema validation should succeed")
def step_then_validation_succeeds(context: Context) -> None:
"""Assert that schema validation succeeded."""
assert context.action_config is not None, (
f"Expected validation to succeed but got error: {context.action_schema_error}"
)
@then("the action schema validation should fail")
def step_then_validation_fails(context: Context) -> None:
"""Assert that schema validation failed."""
assert context.action_config is None, "Expected validation to fail but it succeeded"
assert context.action_schema_error is not None
@then('the action schema error should mention "{text}"')
def step_then_error_mentions(context: Context, text: str) -> None:
"""Assert the error message contains expected text."""
assert context.action_schema_error is not None, "No error was captured"
assert text.lower() in context.action_schema_error.lower(), (
f"Expected error to mention '{text}', got: {context.action_schema_error}"
)
@then('the action config name should be "{expected}"')
def step_then_config_name(context: Context, expected: str) -> None:
"""Assert the parsed config name."""
assert context.action_config is not None
assert context.action_config.name == expected
@then('the action config strategy_actor should be "{expected}"')
def step_then_config_strategy_actor(context: Context, expected: str) -> None:
"""Assert the parsed strategy_actor."""
assert context.action_config is not None
assert context.action_config.strategy_actor == expected
@then('the action config execution_actor should be "{expected}"')
def step_then_config_execution_actor(context: Context, expected: str) -> None:
"""Assert the parsed execution_actor."""
assert context.action_config is not None
assert context.action_config.execution_actor == expected
@then('the action config estimation_actor should be "{expected}"')
def step_then_config_estimation_actor(context: Context, expected: str) -> None:
"""Assert the parsed estimation_actor."""
assert context.action_config is not None
assert context.action_config.estimation_actor == expected
@then('the action config review_actor should be "{expected}"')
def step_then_config_review_actor(context: Context, expected: str) -> None:
"""Assert the parsed review_actor."""
assert context.action_config is not None
assert context.action_config.review_actor == expected
@then('the action config apply_actor should be "{expected}"')
def step_then_config_apply_actor(context: Context, expected: str) -> None:
"""Assert the parsed apply_actor."""
assert context.action_config is not None
assert context.action_config.apply_actor == expected
@then('the action config invariant_actor should be "{expected}"')
def step_then_config_invariant_actor(context: Context, expected: str) -> None:
"""Assert the parsed invariant_actor."""
assert context.action_config is not None
assert context.action_config.invariant_actor == expected
@then("the action config reusable should be true")
def step_then_reusable_true(context: Context) -> None:
"""Assert reusable is True."""
assert context.action_config is not None
assert context.action_config.reusable is True
@then("the action config read_only should be false")
def step_then_read_only_false(context: Context) -> None:
"""Assert read_only is False."""
assert context.action_config is not None
assert context.action_config.read_only is False
@then('the action config state should be "{expected}"')
def step_then_config_state(context: Context, expected: str) -> None:
"""Assert the parsed state."""
assert context.action_config is not None
assert context.action_config.state == expected
@then("the action config invariants list should be empty")
def step_then_invariants_empty(context: Context) -> None:
"""Assert invariants list is empty."""
assert context.action_config is not None
assert context.action_config.invariants == []
@then("the action config arguments list should be empty")
def step_then_arguments_empty(context: Context) -> None:
"""Assert arguments list is empty."""
assert context.action_config is not None
assert context.action_config.arguments == []
@then("the action config should have {count:d} arguments")
def step_then_argument_count(context: Context, count: int) -> None:
"""Assert the number of parsed arguments."""
assert context.action_config is not None
assert len(context.action_config.arguments) == count
@then('action argument {idx:d} name should be "{expected}"')
def step_then_argument_name(context: Context, idx: int, expected: str) -> None:
"""Assert an argument name by index."""
assert context.action_config is not None
assert context.action_config.arguments[idx].name == expected
@then('action argument {idx:d} type should be "{expected}"')
def step_then_argument_type(context: Context, idx: int, expected: str) -> None:
"""Assert an argument type by index."""
assert context.action_config is not None
assert context.action_config.arguments[idx].type == expected
@then("the action config should have {count:d} invariants")
def step_then_invariant_count(context: Context, count: int) -> None:
"""Assert the number of parsed invariants."""
assert context.action_config is not None
assert len(context.action_config.invariants) == count
@then('action invariant {idx:d} should be "{expected}"')
def step_then_invariant_value(context: Context, idx: int, expected: str) -> None:
"""Assert an invariant value by index."""
assert context.action_config is not None
assert context.action_config.invariants[idx] == expected
@then('the action config model_dump should contain key "{key}"')
def step_then_model_dump_contains_key(context: Context, key: str) -> None:
"""Assert the model_dump dict contains a key."""
assert context.action_config is not None
data: dict[str, Any] = context.action_config.model_dump()
assert key in data, f"Key '{key}' not found in model_dump: {list(data.keys())}"