"""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())}"