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
459 lines
17 KiB
Python
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())}"
|