Files
placeholder/features/steps/config_cli_coverage_boost_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

483 lines
18 KiB
Python

"""Step definitions for config_cli_coverage_boost.feature.
Targets uncovered lines in cleveragents/cli/commands/config.py:
- Line 135: _env_var_for_key returning entry.env_var for registered key
- Lines 145-146: _resolve_source error path returning "default"
- Lines 155-156: _resolution_chain error path returning []
- Line 210: config_set --project on non-project-scopable key
- Line 213: config_set --project with corrupted project section
- Line 216: config_set --project with corrupted project overrides
- Line 306: config_get verbose non-rich with Path fspath in chain
- Lines 398-401: config_list --project key regex filter skip
- Line 405: config_list --project value regex filter skip
- Line 410: config_list --project secret masking
- Line 430: config_list --project non-rich Path fspath serialisation
- Lines 434-452: config_list --project rich table rendering
- Line 501: config_list all non-rich Path fspath serialisation
All step text uses a 'cfg-boost' prefix to avoid collisions with other
step definition files.
"""
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 MagicMock, patch
import tomlkit
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.application.services.config_service import (
_REGISTRY,
ConfigLevel,
ResolvedValue,
)
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import (
_env_var_for_key,
_resolution_chain,
_resolve_source,
)
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 directory
# ---------------------------------------------------------------------------
def _setup_boost_temp(context: Context) -> None:
"""Redirect _CONFIG_DIR/_CONFIG_PATH to a fresh temp directory."""
if not hasattr(context, "_boost_tmpdir"):
context._boost_tmpdir = tempfile.mkdtemp(prefix="cfg_boost_")
tmp = Path(context._boost_tmpdir)
context._boost_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
context._boost_path_patch = patch.object(
config_mod, "_CONFIG_PATH", tmp / "config.toml"
)
context._boost_dir_patch.start()
context._boost_path_patch.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers: list[Any] = []
context._cleanup_handlers.append(lambda: _teardown_boost_temp(context))
def _teardown_boost_temp(context: Context) -> None:
for attr in ("_boost_dir_patch", "_boost_path_patch"):
patcher = getattr(context, attr, None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
tmpdir = getattr(context, "_boost_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
def _write_toml(data: dict[str, Any]) -> None:
"""Write a dict to the temp config.toml using tomlkit."""
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
config_path.parent.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
for key, value in data.items():
doc[key] = value
with open(config_path, "w") as fh:
tomlkit.dump(doc, fh)
def _write_project_override(project: str, key: str, value: str) -> None:
"""Write a TOML config file with a project override."""
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
config_path.parent.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
proj_table = tomlkit.table(is_super_table=True)
inner = tomlkit.table()
# Coerce numeric strings
try:
inner[key] = int(value)
except ValueError:
inner[key] = value
proj_table[project] = inner
doc["project"] = proj_table
with open(config_path, "w") as fh:
tomlkit.dump(doc, fh)
# ===================================================================
# Background
# ===================================================================
@given("a cfg-boost isolated temp config directory")
def step_cfg_boost_temp_dir(context: Context) -> None:
_setup_boost_temp(context)
# ===================================================================
# _env_var_for_key — line 135
# ===================================================================
@when('I call cfg-boost _env_var_for_key with a registered key "{key}"')
def step_cfg_boost_env_var_for_key(context: Context, key: str) -> None:
context.cfg_boost_env_var_result = _env_var_for_key(key)
@then('the cfg-boost returned env var should match the registry for "{key}"')
def step_cfg_boost_check_env_var(context: Context, key: str) -> None:
entry = _REGISTRY[key]
assert context.cfg_boost_env_var_result == entry.env_var, (
f"Expected '{entry.env_var}', got '{context.cfg_boost_env_var_result}'"
)
# ===================================================================
# _resolve_source error path — lines 145-146
# ===================================================================
@when("I call cfg-boost _resolve_source with a key that triggers ValueError")
def step_cfg_boost_resolve_source_error(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.resolve.side_effect = ValueError("boom")
with patch.object(config_mod, "_get_service", return_value=mock_svc):
context.cfg_boost_resolve_source_result = _resolve_source("any.key")
@then('the cfg-boost returned source should be "{source}"')
def step_cfg_boost_check_source(context: Context, source: str) -> None:
assert context.cfg_boost_resolve_source_result == source, (
f"Expected '{source}', got '{context.cfg_boost_resolve_source_result}'"
)
# ===================================================================
# _resolution_chain error path — lines 155-156
# ===================================================================
@when("I call cfg-boost _resolution_chain with a key that triggers KeyError")
def step_cfg_boost_resolution_chain_error(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.resolve.side_effect = KeyError("missing")
with patch.object(config_mod, "_get_service", return_value=mock_svc):
context.cfg_boost_chain_result = _resolution_chain("any.key")
@then("the cfg-boost returned chain should be empty")
def step_cfg_boost_check_chain_empty(context: Context) -> None:
assert context.cfg_boost_chain_result == [], (
f"Expected [], got {context.cfg_boost_chain_result}"
)
# ===================================================================
# config_set --project non-scopable — line 210
# ===================================================================
@when('I invoke cfg-boost config set "{key}" "{value}" with project "{project}"')
def step_cfg_boost_set_project(
context: Context, key: str, value: str, project: str
) -> None:
context.cfg_boost_result = _runner.invoke(
config_app, ["set", key, value, "--project", project]
)
@then("the cfg-boost CLI result should have failed")
def step_cfg_boost_cli_failed(context: Context) -> None:
assert context.cfg_boost_result.exit_code != 0, (
f"Expected non-zero exit, got {context.cfg_boost_result.exit_code}: "
f"{context.cfg_boost_result.output}"
)
@then('the cfg-boost CLI output should include "{text}"')
def step_cfg_boost_cli_output_includes(context: Context, text: str) -> None:
output = context.cfg_boost_result.output
assert text in output, f"Expected '{text}' in output:\n{output}"
# ===================================================================
# config_set --project corrupted project section — line 213
# ===================================================================
@given('the cfg-boost config file has a non-dict "project" value')
def step_cfg_boost_write_corrupted_project_section(context: Context) -> None:
_write_toml({"project": "not-a-dict"})
# ===================================================================
# config_set --project corrupted proj overrides — line 216
# ===================================================================
@given('the cfg-boost config file has a non-dict project override for "{project}"')
def step_cfg_boost_write_corrupted_proj_override(
context: Context, project: str
) -> None:
data: dict[str, Any] = {"project": {project: "not-a-dict"}}
_write_toml(data)
@then("the cfg-boost CLI result should have succeeded")
def step_cfg_boost_cli_succeeded(context: Context) -> None:
assert context.cfg_boost_result.exit_code == 0, (
f"Expected exit 0, got {context.cfg_boost_result.exit_code}: "
f"{context.cfg_boost_result.output}"
)
# ===================================================================
# config_get verbose non-rich with Path value in chain — line 306
# ===================================================================
@when(
'I invoke cfg-boost config get "{key}" verbose format "{fmt}" with path-like chain'
)
def step_cfg_boost_get_verbose_path(context: Context, key: str, fmt: str) -> None:
# Build a mock resolved value whose chain contains a Path object
chain_with_path = [
{"source": "cli_flag", "value": None},
{"source": "env_var", "value": None},
{"source": "project", "value": None},
{"source": "global", "value": Path("/tmp/fake/path")},
{"source": "default", "value": Path("/default/path")},
]
mock_resolved = ResolvedValue(
key=key,
value=Path("/tmp/fake/path"),
source=ConfigLevel.GLOBAL,
chain=chain_with_path,
)
mock_svc = MagicMock()
mock_svc.resolve.return_value = mock_resolved
with patch.object(config_mod, "_get_service", return_value=mock_svc):
context.cfg_boost_result = _runner.invoke(
config_app,
["get", key, "--verbose", "--format", fmt],
)
@then("the cfg-boost JSON resolution chain entries should have string values")
def step_cfg_boost_json_chain_strings(context: Context) -> None:
parsed = json.loads(context.cfg_boost_result.output)
data = _unwrap_envelope(parsed)
chain = data.get("resolution_chain", [])
assert len(chain) > 0, "Expected non-empty resolution chain"
for entry in chain:
val = entry.get("value")
if val is not None:
assert isinstance(val, str), (
f"Expected string in chain, got {type(val)}: {val}"
)
# ===================================================================
# config_list --project key regex filter — lines 398-401
# ===================================================================
@given(
'the cfg-boost config file has project "{project}" override "{key}" as "{value}"'
)
def step_cfg_boost_write_project_override(
context: Context, project: str, key: str, value: str
) -> None:
_write_project_override(project, key, value)
@when('I invoke cfg-boost config list with project "{project}" pattern "{pattern}"')
def step_cfg_boost_list_project_pattern(
context: Context, project: str, pattern: str
) -> None:
context.cfg_boost_result = _runner.invoke(
config_app, ["list", pattern, "--project", project]
)
@then("the cfg-boost list output should show no matching overrides")
def step_cfg_boost_no_matching(context: Context) -> None:
output = context.cfg_boost_result.output
assert (
"No project-scoped overrides match" in output
or context.cfg_boost_result.exit_code == 0
), f"Unexpected output:\n{output}"
# ===================================================================
# config_list --project value filter — line 405
# ===================================================================
@when(
'I invoke cfg-boost config list with project "{project}" value-filter "{valfilter}"'
)
def step_cfg_boost_list_project_valfilter(
context: Context, project: str, valfilter: str
) -> None:
context.cfg_boost_result = _runner.invoke(
config_app,
["list", "--project", project, "--filter-values", valfilter],
)
# ===================================================================
# config_list --project secret masking — line 410
# ===================================================================
@given(
'the cfg-boost config file has project "{project}" '
'secret override "{key}" as "{value}"'
)
def step_cfg_boost_write_secret_override(
context: Context, project: str, key: str, value: str
) -> None:
_write_project_override(project, key, value)
@when('I invoke cfg-boost config list project "{project}" format "{fmt}"')
def step_cfg_boost_list_project_fmt(context: Context, project: str, fmt: str) -> None:
context.cfg_boost_result = _runner.invoke(
config_app, ["list", "--project", project, "--format", fmt]
)
@then("the cfg-boost JSON list should show masked secret values")
def step_cfg_boost_json_secret_masked(context: Context) -> None:
output = context.cfg_boost_result.output.strip()
data = json.loads(output)
assert isinstance(data, list), f"Expected list, got {type(data)}"
# Find entries that look like secrets
secret_entries = [
e
for e in data
if "api-key" in e.get("key", "")
or "token" in e.get("key", "")
or "secret" in e.get("key", "")
or "password" in e.get("key", "")
]
assert len(secret_entries) > 0, f"No secret entries found in: {data}"
for entry in secret_entries:
assert entry["value"] == "****", (
f"Expected masked value '****' for {entry['key']}, got '{entry['value']}'"
)
# ===================================================================
# config_list --project non-rich Path fspath — line 430
# ===================================================================
@when(
'I invoke cfg-boost config list project "{project}" format "{fmt}" with path value'
)
def step_cfg_boost_list_project_path_value(
context: Context, project: str, fmt: str
) -> None:
# Mock get_project_overrides to return a dict containing a Path object
mock_svc = MagicMock()
mock_svc.get_project_overrides.return_value = {
"core.data-dir": Path("/tmp/overridden"),
}
with patch.object(config_mod, "_get_service", return_value=mock_svc):
context.cfg_boost_result = _runner.invoke(
config_app,
["list", "--project", project, "--format", fmt],
)
@then("the cfg-boost JSON list entries should have only primitive values")
def step_cfg_boost_json_list_primitives(context: Context) -> None:
output = context.cfg_boost_result.output.strip()
data = json.loads(output)
assert isinstance(data, list), f"Expected list, got {type(data)}"
for entry in data:
val = entry.get("value")
if val is not None:
assert isinstance(val, (str, int, float, bool)), (
f"Expected primitive type, got {type(val)}: {val}"
)
# ===================================================================
# config_list --project rich table — lines 434-452
# ===================================================================
@when('I invoke cfg-boost config list project "{project}" in default rich format')
def step_cfg_boost_list_project_rich(context: Context, project: str) -> None:
context.cfg_boost_result = _runner.invoke(
config_app, ["list", "--project", project]
)
# ===================================================================
# config_list all non-rich Path fspath — line 501
# ===================================================================
@when('I invoke cfg-boost config list format "{fmt}" with path value in resolve_all')
def step_cfg_boost_list_all_path(context: Context, fmt: str) -> None:
# Build resolve_all results with one Path value to trigger line 501
results: dict[str, ResolvedValue] = {}
for key in _REGISTRY:
results[key] = ResolvedValue(
key=key,
value=_REGISTRY[key].default,
source=ConfigLevel.DEFAULT,
)
# Override one key with a Path value
results["core.data-dir"] = ResolvedValue(
key="core.data-dir",
value=Path("/tmp/path/value"),
source=ConfigLevel.GLOBAL,
)
mock_svc = MagicMock()
mock_svc.resolve_all.return_value = results
with patch.object(config_mod, "_get_service", return_value=mock_svc):
context.cfg_boost_result = _runner.invoke(config_app, ["list", "--format", fmt])
@then("the cfg-boost JSON all-keys list should have stringified path values")
def step_cfg_boost_json_all_stringified(context: Context) -> None:
output = context.cfg_boost_result.output.strip()
data = json.loads(output)
assert isinstance(data, list), f"Expected list, got {type(data)}"
# Find the core.data-dir entry and verify its value is a string
data_dir_entries = [e for e in data if e.get("key") == "core.data-dir"]
assert len(data_dir_entries) > 0, "Expected core.data-dir in list output"
for entry in data_dir_entries:
val = entry["value"]
assert isinstance(val, str), (
f"Expected string for core.data-dir, got {type(val)}: {val}"
)
assert "/tmp/path/value" in val, f"Expected path string in value, got: {val}"