Files
temp/features/steps/config_cli_safety_net_coverage_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

736 lines
28 KiB
Python

"""Step definitions for config_cli_safety_net_coverage.feature.
Safety-net tests exercising every function in config.py to maintain
100% line and branch coverage. All step text uses a 'safety-net' prefix
to avoid collisions with config_cli_steps.py and
config_cli_uncovered_branches_steps.py.
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import tempfile
from io import StringIO
from pathlib import Path
from typing import Any
from unittest.mock import patch
import typer
import yaml
from behave import given, then, when
from behave.runner import Context
from rich.console import Console
from typer.testing import CliRunner
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import (
_env_var_for_key,
_is_secret_key,
_mask_value,
_normalize_key,
_read_config_file,
_resolution_chain,
_resolve_source,
_settings_fields,
_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 directory for config file operations
# ---------------------------------------------------------------------------
def _setup_safety_net_temp(context: Context) -> None:
"""Redirect _CONFIG_DIR/_CONFIG_PATH to a fresh temp directory."""
if not hasattr(context, "_sn_tmpdir"):
context._sn_tmpdir = tempfile.mkdtemp(prefix="cfg_safety_net_")
tmp = Path(context._sn_tmpdir)
context._sn_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
context._sn_path_patch = patch.object(
config_mod, "_CONFIG_PATH", tmp / "config.toml"
)
context._sn_dir_patch.start()
context._sn_path_patch.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers: list[Any] = []
context._cleanup_handlers.append(lambda: _teardown_safety_net_temp(context))
def _teardown_safety_net_temp(context: Context) -> None:
for attr in ("_sn_dir_patch", "_sn_path_patch"):
patcher = getattr(context, attr, None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
tmpdir = getattr(context, "_sn_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
# ===================================================================
# Background / Given
# ===================================================================
@given("a safety-net isolated temp config directory")
def step_sn_temp_dir(context: Context) -> None:
_setup_safety_net_temp(context)
@given('a safety-net toml config file containing key "{key}" with value "{value}"')
def step_sn_write_toml(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()
# Coerce booleans
if value.lower() == "true":
doc[key] = True
elif value.lower() == "false":
doc[key] = False
else:
doc[key] = value
with open(config_path, "w") as fh:
tomlkit.dump(doc, fh)
@given('the safety-net CLI has previously set "{key}" to "{value}"')
def step_sn_pre_set(context: Context, key: str, value: str) -> None:
result = _runner.invoke(config_app, ["set", key, value])
assert result.exit_code == 0, f"Pre-set failed: {result.output}"
@given('the safety-net env var "{var}" is set to "{value}"')
def step_sn_set_env(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))
@given("safety-net settings fields include a Path-typed value")
def step_sn_mock_path_field(context: Context) -> None:
"""Patch _settings_fields to include a Path value that must be serialised."""
real_fields = _settings_fields()
# Inject a Path value into a known field
real_fields["log_level"] = Path("/mock/safety/net/path")
context._sn_fields_patch = patch.object(
config_mod, "_settings_fields", return_value=real_fields
)
context._sn_fields_patch.start()
context._cleanup_handlers.append(context._sn_fields_patch.stop)
@given("safety-net settings fields include a secret key with a non-pattern value")
def step_sn_mock_secret_field(context: Context) -> None:
"""Patch _settings_fields to include a field whose name matches _SECRET_PATTERNS.
Uses a value that does NOT match redact_value patterns (no sk-, tok_, etc.)
so that format_output's _redact_data won't double-mask it.
"""
# Use a plain secret value that won't match redact_value regex patterns
context._sn_actual_secret = "my-secret-value-here"
mock_fields = {
"log_level": "INFO",
"api_key": context._sn_actual_secret,
}
mock_defaults = {
"log_level": "INFO",
"api_key": None,
}
context._sn_fields_patch2 = patch.object(
config_mod, "_settings_fields", return_value=mock_fields
)
context._sn_defaults_patch2 = patch.object(
config_mod, "_settings_defaults", return_value=mock_defaults
)
context._sn_resolve_patch2 = patch.object(
config_mod, "_resolve_source", return_value="default"
)
context._sn_fields_patch2.start()
context._sn_defaults_patch2.start()
context._sn_resolve_patch2.start()
context._cleanup_handlers.append(context._sn_fields_patch2.stop)
context._cleanup_handlers.append(context._sn_defaults_patch2.stop)
context._cleanup_handlers.append(context._sn_resolve_patch2.stop)
@given("safety-net settings fields have a value different from default")
def step_sn_mock_modified_field(context: Context) -> None:
"""Patch fields so that a value differs from its default."""
mock_fields = {"log_level": "DEBUG"}
mock_defaults = {"log_level": "INFO"}
context._sn_fields_patch3 = patch.object(
config_mod, "_settings_fields", return_value=mock_fields
)
context._sn_defaults_patch3 = patch.object(
config_mod, "_settings_defaults", return_value=mock_defaults
)
context._sn_resolve_patch3 = patch.object(
config_mod, "_resolve_source", return_value="config_file"
)
context._sn_fields_patch3.start()
context._sn_defaults_patch3.start()
context._sn_resolve_patch3.start()
context._cleanup_handlers.append(context._sn_fields_patch3.stop)
context._cleanup_handlers.append(context._sn_defaults_patch3.stop)
context._cleanup_handlers.append(context._sn_resolve_patch3.stop)
# ===================================================================
# _normalize_key (L91-96)
# ===================================================================
@when('the safety-net normalizer processes key "{key}"')
def step_sn_normalize(context: Context, key: str) -> None:
context._sn_normalized = _normalize_key(key)
@then('the safety-net normalized result should be "{expected}"')
def step_sn_normalized_equals(context: Context, expected: str) -> None:
assert context._sn_normalized == expected, (
f"Expected '{expected}', got '{context._sn_normalized}'"
)
# ===================================================================
# _is_secret_key (L116-118)
# ===================================================================
@when('the safety-net secret checker inspects key "{key}"')
def step_sn_is_secret(context: Context, key: str) -> None:
context._sn_is_secret = _is_secret_key(key)
@then("the safety-net secret check result should be true")
def step_sn_secret_true(context: Context) -> None:
assert context._sn_is_secret is True, "Expected secret=True"
@then("the safety-net secret check result should be false")
def step_sn_secret_false(context: Context) -> None:
assert context._sn_is_secret is False, "Expected secret=False"
# ===================================================================
# _mask_value (L121-123)
# ===================================================================
@when('the safety-net masker masks value "{value}"')
def step_sn_mask(context: Context, value: str) -> None:
context._sn_masked = _mask_value(value)
@when("the safety-net masker masks an empty string value")
def step_sn_mask_empty(context: Context) -> None:
context._sn_masked = _mask_value("")
@then('the safety-net masked output should be "{expected}"')
def step_sn_masked_equals(context: Context, expected: str) -> None:
assert context._sn_masked == expected, (
f"Expected '{expected}', got '{context._sn_masked}'"
)
# ===================================================================
# _env_var_for_key (L158-160)
# ===================================================================
@when('the safety-net env var builder processes key "{key}"')
def step_sn_env_var(context: Context, key: str) -> None:
context._sn_env_var = _env_var_for_key(key)
@then('the safety-net env var name should be "{expected}"')
def step_sn_env_var_equals(context: Context, expected: str) -> None:
assert context._sn_env_var == expected, (
f"Expected '{expected}', got '{context._sn_env_var}'"
)
# ===================================================================
# _read_config_file (L126-131)
# ===================================================================
@when("the safety-net reader reads the config file")
def step_sn_read_config(context: Context) -> None:
context._sn_read_result = _read_config_file()
@then("the safety-net read result should be an empty dict")
def step_sn_read_empty(context: Context) -> None:
assert context._sn_read_result == {}, (
f"Expected empty dict, got {context._sn_read_result}"
)
@then('the safety-net read result should contain key "{key}"')
def step_sn_read_has_key(context: Context, key: str) -> None:
assert key in context._sn_read_result, (
f"Key '{key}' not in {context._sn_read_result}"
)
# ===================================================================
# _write_config_file (L134-155) - create new
# ===================================================================
@when('the safety-net writer writes key "{key}" with value "{value}"')
def step_sn_write_config(context: Context, key: str, value: str) -> None:
_write_config_file({key: value})
@then('the safety-net config file should exist and contain key "{key}"')
def step_sn_file_has_key(context: Context, key: str) -> None:
import tomllib
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
assert config_path.exists(), f"Config file does not exist at {config_path}"
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
assert key in data, f"Key '{key}' not in file data: {data}"
# ===================================================================
# _settings_fields (L62-70)
# ===================================================================
@when("the safety-net fields loader retrieves all settings fields")
def step_sn_settings_fields(context: Context) -> None:
context._sn_fields = _settings_fields()
@then("the safety-net fields result should be a non-empty dict")
def step_sn_fields_nonempty(context: Context) -> None:
assert isinstance(context._sn_fields, dict), (
f"Expected dict, got {type(context._sn_fields)}"
)
assert len(context._sn_fields) > 0, "Expected non-empty dict of fields"
# ===================================================================
# _validate_key (L99-113) - unknown key
# ===================================================================
@when('the safety-net validator checks unknown key "{key}"')
def step_sn_validate_unknown(context: Context, key: str) -> None:
context._sn_validate_exc = None
try:
_validate_key(key)
except typer.BadParameter as exc:
context._sn_validate_exc = exc
@then('the safety-net validator should raise BadParameter with "{msg}"')
def step_sn_validate_bad_param(context: Context, msg: str) -> None:
assert context._sn_validate_exc is not None, (
"Expected typer.BadParameter but no exception raised"
)
assert msg.lower() in str(context._sn_validate_exc).lower(), (
f"Expected '{msg}' in: {context._sn_validate_exc}"
)
@when('the safety-net validator checks valid key "{key}"')
def step_sn_validate_valid(context: Context, key: str) -> None:
context._sn_validate_result = _validate_key(key)
@then('the safety-net validator should return "{expected}"')
def step_sn_validate_returns(context: Context, expected: str) -> None:
assert context._sn_validate_result == expected, (
f"Expected '{expected}', got '{context._sn_validate_result}'"
)
# ===================================================================
# _resolve_source (L163-174)
# ===================================================================
@when('the safety-net source resolver checks key "{key}"')
def step_sn_resolve_source(context: Context, key: str) -> None:
context._sn_source = _resolve_source(key)
@then('the safety-net resolved source should be "{expected}"')
def step_sn_source_equals(context: Context, expected: str) -> None:
assert context._sn_source == expected, (
f"Expected '{expected}', got '{context._sn_source}'"
)
# ===================================================================
# _resolution_chain (L177-209)
# ===================================================================
@when('the safety-net chain builder builds chain for key "{key}"')
def step_sn_resolution_chain(context: Context, key: str) -> None:
context._sn_chain = _resolution_chain(key)
@then("the safety-net chain should have exactly {count:d} entries")
def step_sn_chain_count(context: Context, count: int) -> None:
assert len(context._sn_chain) == count, (
f"Expected {count} entries, got {len(context._sn_chain)}"
)
@then('the safety-net chain sources should be "{sources}"')
def step_sn_chain_sources(context: Context, sources: str) -> None:
expected = [s.strip() for s in sources.split(",")]
actual = [entry["source"] for entry in context._sn_chain]
assert actual == expected, f"Expected sources {expected}, got {actual}"
# ===================================================================
# config_set - type coercion (L241-248)
# ===================================================================
@when('the safety-net CLI sets key "{key}" to value "{value}" with format "{fmt}"')
def step_sn_cli_set(context: Context, key: str, value: str, fmt: str) -> None:
context._sn_result = _runner.invoke(
config_app, ["set", key, value, "--format", fmt]
)
@then("the safety-net set output should be valid JSON")
def step_sn_set_valid_json(context: Context) -> None:
result = context._sn_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._sn_set_json = _unwrap_envelope(parsed)
@then('the safety-net set JSON field "value" should be boolean true')
def step_sn_set_bool_true(context: Context) -> None:
assert context._sn_set_json["value"] is True, (
f"Expected True, got {context._sn_set_json['value']!r}"
)
@then('the safety-net set JSON field "value" should be boolean false')
def step_sn_set_bool_false(context: Context) -> None:
assert context._sn_set_json["value"] is False, (
f"Expected False, got {context._sn_set_json['value']!r}"
)
@then('the safety-net set JSON field "value" should be integer {expected:d}')
def step_sn_set_int(context: Context, expected: int) -> None:
val = context._sn_set_json["value"]
assert val == expected and isinstance(val, int), (
f"Expected int {expected}, got {val!r} ({type(val).__name__})"
)
@then('the safety-net set JSON field "value" should be float {expected:g}')
def step_sn_set_float(context: Context, expected: float) -> None:
val = context._sn_set_json["value"]
assert isinstance(val, float) and abs(val - expected) < 1e-9, (
f"Expected float {expected}, got {val!r} ({type(val).__name__})"
)
@then('the safety-net set JSON field "value" should be string "{expected}"')
def step_sn_set_string(context: Context, expected: str) -> None:
val = context._sn_set_json["value"]
assert val == expected and isinstance(val, str), (
f"Expected string '{expected}', got {val!r} ({type(val).__name__})"
)
@then('the safety-net set JSON field "previous_value" should be string "{expected}"')
def step_sn_set_previous(context: Context, expected: str) -> None:
val = context._sn_set_json["previous_value"]
assert val == expected, f"Expected previous '{expected}', got {val!r}"
@then('the safety-net set JSON should contain a "previous_value" field')
def step_sn_set_has_previous(context: Context) -> None:
assert "previous_value" in context._sn_set_json, (
f"Expected 'previous_value' field in: {list(context._sn_set_json.keys())}"
)
@then('the safety-net set rich output should contain "{text}"')
def step_sn_set_rich_contains(context: Context, text: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
assert text in result.output, f"Expected '{text}' in output: {result.output[:500]}"
# ===================================================================
# config_get - rich and non-rich formats (L279-335)
# ===================================================================
@when('the safety-net CLI gets key "{key}" with format "{fmt}"')
def step_sn_cli_get(context: Context, key: str, fmt: str) -> None:
context._sn_result = _runner.invoke(config_app, ["get", key, "--format", fmt])
@when('the safety-net CLI gets key "{key}" with format "{fmt}" and verbose')
def step_sn_cli_get_verbose(context: Context, key: str, fmt: str) -> None:
context._sn_result = _runner.invoke(
config_app, ["get", key, "--format", fmt, "--verbose"]
)
@then('the safety-net get rich output should contain "{text}"')
def step_sn_get_rich_contains(context: Context, text: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
assert text in result.output, f"Expected '{text}' in output: {result.output[:500]}"
@then("the safety-net get output should be valid YAML")
def step_sn_get_valid_yaml(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = yaml.safe_load(result.output.strip())
assert parsed is not None, "YAML parsed to None"
context._sn_get_yaml = parsed
@then('the safety-net get YAML should contain key "{key}"')
def step_sn_get_yaml_key(context: Context, key: str) -> None:
assert key in context._sn_get_yaml, (
f"Key '{key}' not in YAML: {list(context._sn_get_yaml.keys())}"
)
@then("the safety-net get output should be valid JSON with type field")
def step_sn_get_json_type(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
assert "type" in data, f"'type' not in JSON keys: {list(data.keys())}"
assert "key" in data, f"'key' not in JSON keys: {list(data.keys())}"
assert "resolution_chain" in data, (
f"'resolution_chain' not in JSON: {list(data.keys())}"
)
# ===================================================================
# config_list - formats (L426-449)
# ===================================================================
@when('the safety-net CLI lists config with format "{fmt}"')
def step_sn_cli_list_format(context: Context, fmt: str) -> None:
context._sn_result = _runner.invoke(config_app, ["list", "--format", fmt])
@then("the safety-net list output should be valid YAML list")
def step_sn_list_valid_yaml(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = yaml.safe_load(result.output.strip())
assert isinstance(parsed, list), f"Expected list, got {type(parsed)}"
assert len(parsed) > 0, "Expected non-empty YAML list"
@then("the safety-net list plain output should contain key-value lines")
def step_sn_list_plain(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
# Plain format should have key: value style lines
assert "key:" in result.output or "log_level" in result.output, (
f"Expected plain key-value in output: {result.output[:500]}"
)
@then("the safety-net list table output should contain column headers")
def step_sn_list_table(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
# Table format should have column header text
output = result.output.lower()
assert "key" in output or "value" in output, (
f"Expected table headers in output: {result.output[:500]}"
)
# ===================================================================
# config_list - combined key + value filter (L391-399)
# ===================================================================
@when(
'the safety-net CLI lists config with key pattern "{pattern}" and value filter "{vfilter}"'
)
def step_sn_cli_list_combined(context: Context, pattern: str, vfilter: str) -> None:
context._sn_result = _runner.invoke(
config_app, ["list", pattern, "--filter-values", vfilter]
)
@then("the safety-net combined filter result should succeed")
def step_sn_combined_ok(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
# ===================================================================
# config_list - Path serialisation (L428-429)
# ===================================================================
@then('the safety-net list JSON output should not contain "PosixPath" or "WindowsPath"')
def step_sn_list_no_path_obj(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
output = result.output
assert "PosixPath" not in output, f"Found PosixPath in output: {output[:500]}"
assert "WindowsPath" not in output, f"Found WindowsPath in output: {output[:500]}"
# ===================================================================
# config_list - secret masking (L408-411)
# ===================================================================
@when("the safety-net CLI lists all config in json format")
def step_sn_cli_list_json(context: Context) -> None:
context._sn_result = _runner.invoke(config_app, ["list", "--format", "json"])
@when("the safety-net CLI lists all config in json format with show-secrets")
def step_sn_cli_list_json_secrets(context: Context) -> None:
context._sn_result = _runner.invoke(
config_app, ["list", "--format", "json", "--show-secrets"]
)
@then('the safety-net list JSON should contain masked value "****" for the secret key')
def step_sn_list_masked(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
api_entries = [e for e in data if e.get("key") == "api_key"]
assert len(api_entries) > 0, f"No api_key entry found in: {data}"
assert api_entries[0]["value"] == "****", (
f"Expected masked ****, got {api_entries[0]['value']!r}"
)
@then("the safety-net list JSON should contain the actual secret value")
def step_sn_list_unmasked(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
api_entries = [e for e in data if e.get("key") == "api_key"]
assert len(api_entries) > 0, f"No api_key entry found in: {data}"
expected = context._sn_actual_secret
assert api_entries[0]["value"] == expected, (
f"Expected '{expected}', got {api_entries[0]['value']!r}"
)
@then('the safety-net list JSON should contain masked "****" for key "{key}"')
def step_sn_list_masked_by_key(context: Context, key: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
entries = [e for e in data if e.get("key") == key]
assert len(entries) > 0, f"No '{key}' entry found in list output"
assert entries[0]["value"] == "****", (
f"Expected masked ****, got {entries[0]['value']!r}"
)
@then('the safety-net list JSON should contain value "{value}" for key "{key}"')
def step_sn_list_value_by_key(context: Context, value: str, key: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
entries = [e for e in data if e.get("key") == key]
assert len(entries) > 0, f"No '{key}' entry found in list output"
assert entries[0]["value"] == value, (
f"Expected '{value}', got {entries[0]['value']!r}"
)
# ===================================================================
# config_list - modified flag (L405-407)
# ===================================================================
@then("the safety-net list JSON should include a modified flag set to true")
def step_sn_list_modified(context: Context) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
data = _unwrap_envelope(parsed)
modified_entries = [e for e in data if e.get("modified") is True]
assert len(modified_entries) > 0, f"No entries with modified=True found in: {data}"
# ===================================================================
# config_get - active marker via patched console (L333)
# ===================================================================
@given("a safety-net patched console for capturing rich output")
def step_sn_patch_console(context: Context) -> None:
"""Patch the module-level console to capture rich output in a StringIO buffer."""
context._sn_console_buf = StringIO()
context._sn_test_console = Console(
file=context._sn_console_buf, no_color=True, width=200
)
context._sn_console_patch = patch.object(
config_mod, "console", context._sn_test_console
)
context._sn_console_patch.start()
context._cleanup_handlers.append(context._sn_console_patch.stop)
@then('the safety-net captured console output should contain "{text}"')
def step_sn_console_contains(context: Context, text: str) -> None:
result = context._sn_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
console_output = context._sn_console_buf.getvalue()
assert text in console_output, (
f"Expected '{text}' in captured console output: {console_output[:500]}"
)