forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
473 lines
17 KiB
Python
473 lines
17 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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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:
|
|
data = json.loads(context.cfg_boost_result.output)
|
|
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}"
|