forked from HAL9000/cleveragents-core
a0df5a4cd0
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
528 lines
19 KiB
Python
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']}"
|
|
)
|