Files
temp/features/steps/config_cli_uncovered_branches_steps.py
freemo a0df5a4cd0 fix(cli): wrap format_output() in spec-required JSON/YAML envelope across all CLI commands
Implements the spec-required JSON/YAML output envelope for all CLI commands
that use format_output(). The envelope structure is:

  {
    "command": "<command that was run>",
    "status": "ok" | "warn" | "error",
    "exit_code": 0,
    "data": { ... command-specific payload ... },
    "timing": { "duration_ms": 123 },
    "messages": [{ "level": "ok", "text": "..." }]
  }

Changes:
- Add _build_envelope() helper to construct the spec-required envelope
- Add optional command, status, exit_code, messages parameters to format_output()
- Wrap json/yaml output in the envelope; plain/table/rich/color unchanged
- Add timing measurement (duration_ms) to all json/yaml outputs
- Add new BDD feature file (cli_json_envelope.feature) with 14 scenarios
  testing envelope field presence, values, and data payload
- Update 14 existing step files to unwrap the envelope when checking
  specific data keys (backward-compatible via _unwrap_envelope() helper)

Closes #3431
2026-04-05 19:48:40 +00:00

528 lines
19 KiB
Python

"""Step definitions for config_cli_uncovered_branches.feature.
Covers missed lines/branches in cleveragents/cli/commands/config.py:
_settings_defaults registry defaults path
_validate_key empty key
_write_config_file existing-file merge
_resolve_source env-var path
config_set non-rich format, --project flag, type coercion
config_get non-rich + Path serialisation, --verbose flag
config_list empty result, invalid regex, secret masking
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
import typer
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.config_service import (
_REGISTRY,
ConfigEntry,
ConfigLevel,
ResolvedValue,
)
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import (
_resolve_source,
_settings_defaults,
_validate_key,
_write_config_file,
)
from cleveragents.cli.commands.config import (
app as config_app,
)
_runner = CliRunner()
_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}
def _unwrap_envelope(parsed: Any) -> Any:
"""Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
return parsed["data"]
return parsed
# ---------------------------------------------------------------------------
# Helpers - isolated temp dir patching
# ---------------------------------------------------------------------------
def _patch_temp_config(context: Context) -> None:
"""Redirect _CONFIG_DIR / _CONFIG_PATH to a fresh temp directory."""
if not hasattr(context, "_cfg_branch_tmpdir"):
context._cfg_branch_tmpdir = tempfile.mkdtemp(prefix="cfg_branch_")
tmp = Path(context._cfg_branch_tmpdir)
context._cfg_branch_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
context._cfg_branch_path_patch = patch.object(
config_mod, "_CONFIG_PATH", tmp / "config.toml"
)
context._cfg_branch_dir_patch.start()
context._cfg_branch_path_patch.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers: list[Any] = []
context._cleanup_handlers.append(lambda: _stop_temp_config(context))
def _stop_temp_config(context: Context) -> None:
for attr in ("_cfg_branch_dir_patch", "_cfg_branch_path_patch"):
patcher = getattr(context, attr, None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
tmpdir = getattr(context, "_cfg_branch_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
# ===================================================================
# _settings_defaults - returns defaults from registry
# ===================================================================
@when("I config cli branch call _settings_defaults")
def step_call_settings_defaults(context: Context) -> None:
context._cfg_branch_defaults_real = _settings_defaults()
@then("the config cli branch defaults should contain registry default values")
def step_defaults_registry_values(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
# core.log.level should have default "FATAL"
assert "core.log.level" in defaults, f"core.log.level missing: {defaults.keys()}"
assert defaults["core.log.level"] == "FATAL", (
f"Expected 'FATAL', got {defaults['core.log.level']}"
)
# plan.concurrency should have default 4
assert "plan.concurrency" in defaults, (
f"plan.concurrency missing: {defaults.keys()}"
)
assert defaults["plan.concurrency"] == 4, (
f"Expected 4, got {defaults['plan.concurrency']}"
)
# core.log.file-enabled should have default True
assert "core.log.file-enabled" in defaults, (
f"core.log.file-enabled missing: {defaults.keys()}"
)
assert defaults["core.log.file-enabled"] is True, (
f"Expected True, got {defaults['core.log.file-enabled']}"
)
# ===================================================================
# _settings_defaults - None default entry (mocked)
# ===================================================================
@given("a config cli branch mocked registry entry with None default")
def step_mock_registry_none_default(context: Context) -> None:
"""Add a temporary registry entry with None as the default value."""
context._cfg_branch_mock_key = "_test_none_default_key"
context._cfg_branch_orig_entry = _REGISTRY.get(context._cfg_branch_mock_key)
_REGISTRY[context._cfg_branch_mock_key] = ConfigEntry(
key=context._cfg_branch_mock_key,
python_type=str,
default=None,
env_var="CLEVERAGENTS_TEST_NONE",
project_scopable=False,
description="Test entry with None default",
section="test",
)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: _cleanup_mock_registry(context))
def _cleanup_mock_registry(context: Context) -> None:
key = getattr(context, "_cfg_branch_mock_key", None)
if key and key in _REGISTRY:
orig = getattr(context, "_cfg_branch_orig_entry", None)
if orig is not None:
_REGISTRY[key] = orig
else:
del _REGISTRY[key]
@then("the config cli branch defaults should contain None for the mocked key")
def step_defaults_none_value(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
key = context._cfg_branch_mock_key
assert key in defaults, f"{key} missing: {list(defaults.keys())[:10]}"
assert defaults[key] is None, f"Expected None, got {defaults[key]}"
# ===================================================================
# _validate_key - empty key (L116-120)
# ===================================================================
@when("I config cli branch call _validate_key with an empty key")
def step_call_validate_key_empty(context: Context) -> None:
context._cfg_branch_validate_exc = None
try:
_validate_key("")
except typer.BadParameter as exc:
context._cfg_branch_validate_exc = exc
@then("the config cli branch call should raise BadParameter")
def step_validate_key_bad_param(context: Context) -> None:
assert context._cfg_branch_validate_exc is not None, (
"Expected typer.BadParameter but no exception was raised"
)
assert "empty" in str(context._cfg_branch_validate_exc).lower(), (
f"Expected 'empty' in message: {context._cfg_branch_validate_exc}"
)
# ===================================================================
# _write_config_file - existing file merge
# ===================================================================
@given("a config cli branch temp config directory")
def step_temp_config_dir(context: Context) -> None:
_patch_temp_config(context)
@given('a config cli branch existing config file with key "{key}" set to "{value}"')
def step_existing_config_file(context: Context, key: str, value: str) -> None:
import tomlkit
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
config_path.parent.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc[key] = value
with open(config_path, "w") as fh:
tomlkit.dump(doc, fh)
@when('I config cli branch write config with key "{key}" set to "{value}"')
def step_write_config_merge(context: Context, key: str, value: str) -> None:
coerced: Any = value
with contextlib.suppress(ValueError):
coerced = int(value)
_write_config_file({key: coerced})
@then("the config cli branch config file should contain both keys")
def step_config_file_both_keys(context: Context) -> None:
import tomllib
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
assert "core.log.level" in data, f"core.log.level missing: {data}"
assert "plan.concurrency" in data, f"plan.concurrency missing: {data}"
assert data["core.log.level"] == "DEBUG", (
f"core.log.level: {data['core.log.level']}"
)
assert data["plan.concurrency"] == 4, (
f"plan.concurrency: {data['plan.concurrency']}"
)
# ===================================================================
# _resolve_source - env var path
# ===================================================================
@given('the config cli branch env var "{var}" is set to "{value}"')
def step_set_env_var(context: Context, var: str, value: str) -> None:
os.environ[var] = value
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: os.environ.pop(var, None))
@when('I config cli branch call _resolve_source for "{key}"')
def step_call_resolve_source(context: Context, key: str) -> None:
context._cfg_branch_source = _resolve_source(key)
@then('the config cli branch source should be "{expected}"')
def step_source_equals(context: Context, expected: str) -> None:
assert context._cfg_branch_source == expected, (
f"Expected '{expected}', got '{context._cfg_branch_source}'"
)
# ===================================================================
# config_set - non-rich format
# ===================================================================
@when('I config cli branch run config set "{key}" "{value}" with format "{fmt}"')
def step_run_config_set_format(
context: Context, key: str, value: str, fmt: str
) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["set", key, value, "--format", fmt]
)
@then("the config cli branch set result should be valid JSON")
def step_set_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}"
context._cfg_branch_set_json = _unwrap_envelope(parsed)
@then('the config cli branch set JSON should contain key "{key}"')
def step_set_json_has_key(context: Context, key: str) -> None:
assert key in context._cfg_branch_set_json, (
f"Key '{key}' not in {list(context._cfg_branch_set_json.keys())}"
)
# ===================================================================
# config_set - --project flag
# ===================================================================
@when('I config cli branch run config set "{key}" "{value}" with project "{project}"')
def step_run_config_set_project(
context: Context, key: str, value: str, project: str
) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["set", key, value, "--project", project]
)
@then("the config cli branch set result for project should succeed")
def step_set_project_succeed(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
@then('the config cli branch set result should contain scope "{scope}"')
def step_set_result_scope(context: Context, scope: str) -> None:
result = context._cfg_branch_result
assert scope in result.output, f"Expected '{scope}' in output: {result.output}"
# ===================================================================
# config_get - --verbose flag
# ===================================================================
@when('I config cli branch run config get "{key}" with verbose')
def step_run_config_get_verbose(context: Context, key: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["get", key, "--verbose"])
@then("the config cli branch get verbose result should succeed")
def step_get_verbose_succeed(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
@then("the config cli branch get verbose output should contain resolution chain")
def step_get_verbose_chain(context: Context) -> None:
result = context._cfg_branch_result
output = result.output
assert "Resolution chain" in output or "resolution_chain" in output, (
f"Expected resolution chain in output: {output}"
)
# ===================================================================
# config_get - non-rich + Path serialisation
# ===================================================================
@given('config cli branch resolve returns a Path value for "{key}"')
def step_mock_resolve_path(context: Context, key: str) -> None:
"""Patch ConfigService.resolve so config_get sees a Path value."""
path_val = Path("/mock/test/logs")
mock_resolved = ResolvedValue(
key=key,
value=path_val,
source=ConfigLevel.DEFAULT,
chain=[
{"source": ConfigLevel.CLI_FLAG.value, "value": None},
{
"source": ConfigLevel.ENV_VAR.value,
"value": None,
"env_name": f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}",
},
{"source": ConfigLevel.PROJECT.value, "value": None},
{
"source": ConfigLevel.GLOBAL.value,
"value": None,
"path": "/mock/config.toml",
},
{"source": ConfigLevel.DEFAULT.value, "value": Path("/mock/default/logs")},
],
)
# Patch _validate_key to accept the key, and ConfigService.resolve
p1 = patch.object(config_mod, "_validate_key", return_value=key)
p1.start()
from cleveragents.application.services.config_service import ConfigService
p2 = patch.object(ConfigService, "resolve", return_value=mock_resolved)
p2.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.extend([p1.stop, p2.stop])
@when('I config cli branch run config get "{key}" with format "{fmt}"')
def step_run_config_get_format(context: Context, key: str, fmt: str) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["get", key, "--format", fmt]
)
@then("the config cli branch get result should be valid JSON")
def step_get_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}"
context._cfg_branch_get_json = _unwrap_envelope(parsed)
@then("the config cli branch get JSON value should be a string not a Path")
def step_get_json_value_string(context: Context) -> None:
data = context._cfg_branch_get_json
# The top-level "value" should be a string (serialised from Path)
assert isinstance(data["value"], str), (
f"Expected str, got {type(data['value'])}: {data['value']}"
)
assert "/mock/test/logs" in data["value"], (
f"Expected path string, got: {data['value']}"
)
# ===================================================================
# config_set - type coercion (bool, int, float)
# ===================================================================
@then("the config cli branch set JSON value should be bool false")
def step_set_json_bool_false(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] is False, (
f"Expected False, got {parsed['value']} (type {type(parsed['value'])})"
)
@then("the config cli branch set JSON value should be int 8")
def step_set_json_int_8(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] == 8, (
f"Expected 8, got {parsed['value']} (type {type(parsed['value'])})"
)
assert isinstance(parsed["value"], int), (
f"Expected int, got {type(parsed['value'])}"
)
@then("the config cli branch set JSON value should be float 0.5")
def step_set_json_float_half(context: Context) -> None:
parsed = context._cfg_branch_set_json
assert parsed["value"] == 0.5, (
f"Expected 0.5, got {parsed['value']} (type {type(parsed['value'])})"
)
assert isinstance(parsed["value"], float), (
f"Expected float, got {type(parsed['value'])}"
)
# ===================================================================
# config_list - empty result
# ===================================================================
@when('I config cli branch run config list with pattern "{pattern}"')
def step_run_config_list_pattern(context: Context, pattern: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["list", pattern])
@then("the config cli branch list output should say no values match")
def step_list_no_match(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
assert "No configuration values match" in result.output, (
f"Expected 'No configuration values match' in: {result.output}"
)
# ===================================================================
# config_list - invalid regex
# ===================================================================
@then("the config cli branch list result should fail with regex error")
def step_list_regex_error(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code != 0, (
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
)
# ===================================================================
# config_get - unknown key
# ===================================================================
@then("the config cli branch get result should fail with unknown key error")
def step_get_unknown_key_error(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code != 0, (
f"Expected non-zero exit, got {result.exit_code}: {result.output}"
)
# ===================================================================
# config_list - JSON format + secret masking
# ===================================================================
@when('I config cli branch run config list with format "{fmt}"')
def step_run_config_list_format(context: Context, fmt: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["list", "--format", fmt])
@then("the config cli branch list result should be valid JSON")
def step_list_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
assert isinstance(data, list), f"Expected list, got {type(data)}"
context._cfg_branch_list_json = data
@then("the config cli branch list JSON should mask api-key values")
def step_list_json_masked(context: Context) -> None:
items = context._cfg_branch_list_json
# Find entries whose keys contain "api-key" or "token"
secret_entries = [e for e in items if "api-key" in e["key"] or "token" in e["key"]]
# Secret entries with non-None values should be masked
for entry in secret_entries:
if entry["value"] is not None:
assert entry["value"] == "****", (
f"Expected masked '****' for {entry['key']}, got: {entry['value']}"
)