Files
magnus919_agent-skills/binary-analysis/tests/unit/test_cli.py
T

296 lines
12 KiB
Python

"""Unit tests for CLI argument parsing and exit codes."""
from __future__ import annotations
import sys
from pathlib import Path
_scripts_dir = Path(__file__).resolve().parents[2] / "scripts"
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
import argparse
import json
import pytest
from binary_analysis.cli.main import (
_extract_globals,
_positive_duration,
_positive_int,
build_parser,
main,
)
from binary_analysis.domain.enums import ExitCode
class TestArgumentValidators:
"""Tests for custom argparse type validators."""
def test_positive_int_accepts_positive(self) -> None:
"""_positive_int should accept positive values."""
for val in ["1", "10", "100", "99999"]:
assert _positive_int(val) == int(val)
def test_positive_int_rejects_zero(self) -> None:
"""_positive_int should reject zero."""
with pytest.raises(argparse.ArgumentTypeError, match="limit must be a positive integer"):
_positive_int("0")
def test_positive_int_rejects_negative(self) -> None:
"""_positive_int should reject negative values."""
with pytest.raises(argparse.ArgumentTypeError, match="limit must be a positive integer"):
_positive_int("-5")
def test_positive_int_rejects_non_numeric(self) -> None:
"""_positive_int should reject non-numeric input."""
with pytest.raises(argparse.ArgumentTypeError, match="limit must be a positive integer"):
_positive_int("abc")
def test_positive_duration_accepts_positive(self) -> None:
"""_positive_duration should accept positive values."""
for val in ["1", "30", "300", "3600"]:
assert _positive_duration(val) == int(val)
def test_positive_duration_rejects_zero(self) -> None:
"""_positive_duration should reject zero."""
with pytest.raises(argparse.ArgumentTypeError, match="timeout must be a positive duration"):
_positive_duration("0")
def test_positive_duration_rejects_negative(self) -> None:
"""_positive_duration should reject negative values."""
with pytest.raises(argparse.ArgumentTypeError, match="timeout must be a positive duration"):
_positive_duration("-1")
class TestExtractGlobals:
"""Tests for global flag extraction/reordering."""
def test_extract_json_before_subcommand(self) -> None:
"""--json should be moved before the subcommand."""
result = _extract_globals(["doctor", "--json"])
assert result == ["--json", "doctor"]
def test_extract_quiet_before_subcommand(self) -> None:
"""--quiet should be moved before the subcommand."""
result = _extract_globals(["version", "--quiet"])
assert result == ["--quiet", "version"]
def test_extract_limit_with_value(self) -> None:
"""--limit with its value should be moved before the subcommand."""
result = _extract_globals(["doctor", "--limit", "50"])
assert result == ["--limit", "50", "doctor"]
def test_extract_timeout_with_value(self) -> None:
"""--timeout with its value should be moved before the subcommand."""
result = _extract_globals(["doctor", "--timeout", "120"])
assert result == ["--timeout", "120", "doctor"]
def test_preserve_equals_form(self) -> None:
"""--limit=50 should be preserved as-is."""
result = _extract_globals(["doctor", "--limit=50"])
assert result == ["--limit=50", "doctor"]
def test_non_global_flags_unchanged(self) -> None:
"""Non-global flags remain after the subcommand."""
# None of these are global flags, so all remain in the tail after "project"
result = _extract_globals(["project", "create", "--dry-run", "my-proj"])
assert result == ["project", "create", "--dry-run", "my-proj"]
class TestParser:
"""Tests for the argparse parser structure."""
def test_parser_has_global_flags(self) -> None:
"""Parser should define --json, --quiet, --limit, --timeout as global flags."""
parser = build_parser()
# Use parse_known_args to test flag presence
args, _ = parser.parse_known_args(["--json", "--quiet", "--limit", "50", "doctor"])
assert args.json is True
assert args.quiet is True
assert args.limit == 50
def test_parser_help_lists_subcommands(self) -> None:
"""--help should list doctor, bootstrap, version, project."""
parser = build_parser()
help_text = parser.format_help()
assert "doctor" in help_text
assert "bootstrap" in help_text
assert "version" in help_text
assert "project" in help_text
def test_parser_accepts_doctor_command(self) -> None:
"""Parser should accept 'doctor' as a command."""
parser = build_parser()
args = parser.parse_args(["doctor"])
assert args.command == "doctor"
def test_parser_accepts_bootstrap_command(self) -> None:
"""Parser should accept 'bootstrap' as a command."""
parser = build_parser()
args = parser.parse_args(["bootstrap"])
assert args.command == "bootstrap"
def test_parser_accepts_version_command(self) -> None:
"""Parser should accept 'version' as a command."""
parser = build_parser()
args = parser.parse_args(["version"])
assert args.command == "version"
def test_parser_accepts_project_command(self) -> None:
"""Parser should accept 'project' as a command."""
parser = build_parser()
args = parser.parse_args(["project", "create", "my-proj"])
assert args.command == "project"
assert args.project_command == "create"
def test_parser_project_subcommands(self) -> None:
"""Parser should support all project subcommands."""
parser = build_parser()
# subcommands that take a positional project name arg
positional_subcmds = ["create", "status", "clean", "remove", "migrate"]
for subcmd in positional_subcmds:
args = parser.parse_args(["project", subcmd, "test-project"])
assert args.project_command == subcmd
# 'list' takes no positional project name arg
args = parser.parse_args(["project", "list"])
assert args.project_command == "list"
class TestMainExitCodes:
"""Tests for main() exit codes."""
def test_version_json_exit_0(self) -> None:
"""version --json should exit with code 0."""
exit_code = main(["--json", "version"])
assert exit_code == ExitCode.SUCCESS
def test_doctor_json_exit_3_when_missing(self) -> None:
"""doctor --json should exit with code 3 when dependencies are missing."""
exit_code = main(["--json", "doctor"])
# If deps are all present (rare in test env), exit 0; otherwise exit 3
assert exit_code in (ExitCode.SUCCESS, ExitCode.DEPENDENCY_MISSING), (
f"Expected exit 0 or 3, got {exit_code}"
)
def test_invalid_flag_exit_2(self) -> None:
"""--nonexistent-flag should exit with code 2."""
exit_code = main(["--nonexistent-flag", "doctor"])
assert exit_code == ExitCode.INVALID_ARGS
def test_project_no_subcommand_exit_2(self) -> None:
"""project without subcommand should exit with code 2."""
exit_code = main(["project"])
assert exit_code == ExitCode.INVALID_ARGS
def test_negative_limit_exit_2(self) -> None:
"""Negative --limit should exit with code 2."""
exit_code = main(["--limit", "-5", "doctor"])
assert exit_code == ExitCode.INVALID_ARGS
def test_negative_timeout_exit_2(self) -> None:
"""Negative --timeout should exit with code 2."""
exit_code = main(["--timeout", "-1", "doctor"])
assert exit_code == ExitCode.INVALID_ARGS
def test_no_command_exit_2(self) -> None:
"""No command specified should exit with code 2."""
exit_code = main([])
assert exit_code == ExitCode.INVALID_ARGS
def test_unknown_command_exit_2(self) -> None:
"""Unknown command should exit with code 2."""
exit_code = main(["nonexistent-cmd"])
assert exit_code == ExitCode.INVALID_ARGS
class TestJsonEnvelopeOutput:
"""Tests for JSON envelope output from main()."""
def test_version_json_output_is_valid_json(self, capsys: pytest.CaptureFixture) -> None:
"""version --json stdout must be valid parseable JSON."""
main(["--json", "version"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["command"] == "version"
assert "schema_version" in parsed
def test_version_json_envelope_fields(self, capsys: pytest.CaptureFixture) -> None:
"""version --json must contain all envelope fields."""
main(["--json", "version"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
required = [
"schema_version",
"command",
"generated_at",
"duration_ms",
"success",
"partial",
"warnings",
"diagnostics",
"provenance",
"data",
]
for key in required:
assert key in parsed, f"Missing key: {key}"
def test_command_field_matches_invoked(self, capsys: pytest.CaptureFixture) -> None:
"""response.command must match the invoked command name."""
main(["--json", "doctor"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["command"] == "doctor"
def test_project_command_includes_subcommand_name(self, capsys: pytest.CaptureFixture) -> None:
"""project create --json should report 'project create' as command."""
main(["--json", "project", "create", "my-proj"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed["command"] == "project create"
def test_duration_ms_is_non_negative_integer(self, capsys: pytest.CaptureFixture) -> None:
"""duration_ms must be non-negative integer."""
main(["--json", "version"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert isinstance(parsed["duration_ms"], int)
assert parsed["duration_ms"] >= 0
def test_success_and_partial_are_booleans(self, capsys: pytest.CaptureFixture) -> None:
"""success and partial must be JSON booleans."""
main(["--json", "version"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert isinstance(parsed["success"], bool)
assert isinstance(parsed["partial"], bool)
def test_timestamp_is_iso8601(self, capsys: pytest.CaptureFixture) -> None:
"""generated_at must be ISO 8601 with timezone."""
import re
main(["--json", "version"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
ts = parsed["generated_at"]
assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$", ts), (
f"Timestamp '{ts}' is not ISO 8601 with timezone"
)
def test_no_extraneous_text_on_stdout(self, capsys: pytest.CaptureFixture) -> None:
"""--json output should not have extraneous text on stdout."""
main(["--json", "version"])
captured = capsys.readouterr()
# Must be parseable as JSON from the very first character
assert captured.out.strip().startswith("{")
def test_error_exit_2_produces_json_envelope(self, capsys: pytest.CaptureFixture) -> None:
"""Error exit code 2 should still produce JSON envelope when --json is used."""
# Use an invalid flag scenario AFTER a valid command (to trigger our handler, not argparse's)
exit_code = main(["--json", "project"])
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert exit_code == 2
assert parsed["success"] is False
assert parsed["command"] == "project"
assert len(parsed["diagnostics"]) > 0