mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 13:06:30 +03:00
613 lines
23 KiB
Python
613 lines
23 KiB
Python
"""Unit tests for the GhidraAdapter skeleton.
|
|
|
|
Tests cover:
|
|
- GhidraAdapter is a proper BackendAdapter subclass
|
|
- PROJECT_SERIALIZED concurrency declaration
|
|
- Capability detection reports available formats and profiles
|
|
- available_profiles() returns all three profiles
|
|
- validate_profile() accepts valid and rejects unknown profiles
|
|
- initialize() raises RuntimeError when PyGhidra not available
|
|
- Skeleton methods raise NotImplementedError with appropriate messages
|
|
- Error normalization maps Ghidra exceptions to canonical error types
|
|
- Bridge: PyGhidra availability detection
|
|
- Bridge: Ghidra version detection
|
|
- Bridge: JVM initialization guards
|
|
"""
|
|
|
|
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))
|
|
|
|
from typing import ClassVar
|
|
|
|
import pytest
|
|
from binary_analysis.adapters.base import (
|
|
AnalysisProfile,
|
|
BackendAdapter,
|
|
ConcurrencyMode,
|
|
)
|
|
from binary_analysis.adapters.ghidra.adapter import GhidraAdapter
|
|
from binary_analysis.adapters.ghidra.bridge import (
|
|
ensure_initialized,
|
|
get_ghidra_version,
|
|
is_initialized,
|
|
is_pyghidra_available,
|
|
normalize_error,
|
|
)
|
|
from binary_analysis.domain.entities import (
|
|
Address,
|
|
Binary,
|
|
Function,
|
|
Project,
|
|
)
|
|
from binary_analysis.domain.enums import ExitCode
|
|
from binary_analysis.domain.errors import (
|
|
AnalysisFailedError,
|
|
BackendFailureError,
|
|
ImportFailedError,
|
|
OperationTimeoutError,
|
|
UnsupportedFormatError,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter() -> GhidraAdapter:
|
|
"""Return a fresh GhidraAdapter instance."""
|
|
return GhidraAdapter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BackendAdapter interface compliance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInterfaceCompliance:
|
|
"""Verify GhidraAdapter properly implements BackendAdapter."""
|
|
|
|
def test_subclass_of_backend_adapter(self, adapter: GhidraAdapter) -> None:
|
|
"""GhidraAdapter must subclass BackendAdapter."""
|
|
assert isinstance(adapter, BackendAdapter)
|
|
|
|
def test_concurrency_is_project_serialized(self, adapter: GhidraAdapter) -> None:
|
|
"""GhidraAdapter must declare PROJECT_SERIALIZED concurrency."""
|
|
assert adapter.concurrency == ConcurrencyMode.PROJECT_SERIALIZED
|
|
|
|
def test_has_all_required_methods(self, adapter: GhidraAdapter) -> None:
|
|
"""GhidraAdapter must have all BackendAdapter abstract methods.
|
|
|
|
Checks that all methods from the abstract interface exist and
|
|
are callable (raising NotImplementedError is acceptable for
|
|
skeleton methods).
|
|
"""
|
|
required_methods = [
|
|
"initialize",
|
|
"capabilities",
|
|
"available_profiles",
|
|
"validate_profile",
|
|
"import_binary",
|
|
"analyze",
|
|
"get_metadata",
|
|
"get_sections",
|
|
"get_entrypoints",
|
|
"get_imports",
|
|
"get_exports",
|
|
"get_symbols",
|
|
"get_strings",
|
|
"get_functions",
|
|
"decompile",
|
|
"disassemble",
|
|
"read_bytes",
|
|
"get_xrefs",
|
|
"get_callers",
|
|
"get_callees",
|
|
"get_callgraph",
|
|
]
|
|
for method_name in required_methods:
|
|
method = getattr(adapter, method_name, None)
|
|
assert method is not None, f"Missing method: {method_name}"
|
|
assert callable(method), f"Method not callable: {method_name}"
|
|
|
|
def test_concurrency_property_is_enum(self, adapter: GhidraAdapter) -> None:
|
|
"""concurrency property must return a ConcurrencyMode enum value."""
|
|
mode = adapter.concurrency
|
|
assert isinstance(mode, ConcurrencyMode)
|
|
assert mode.value == "PROJECT_SERIALIZED"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Capability detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCapabilities:
|
|
"""Verify capability detection reports correct information."""
|
|
|
|
def test_capabilities_returns_expected_structure(self, adapter: GhidraAdapter) -> None:
|
|
"""capabilities() must have all required top-level keys."""
|
|
caps = adapter.capabilities()
|
|
required_keys = {
|
|
"backend",
|
|
"backend_version",
|
|
"adapter",
|
|
"adapter_version",
|
|
"concurrency",
|
|
"pyghidra_available",
|
|
"jvm_initialized",
|
|
"formats",
|
|
"architectures",
|
|
"profiles",
|
|
"limitations",
|
|
}
|
|
for key in required_keys:
|
|
assert key in caps, f"Missing capability key: {key}"
|
|
|
|
def test_capabilities_backend_is_ghidra(self, adapter: GhidraAdapter) -> None:
|
|
"""backend field must always be 'Ghidra'."""
|
|
caps = adapter.capabilities()
|
|
assert caps["backend"] == "Ghidra"
|
|
|
|
def test_capabilities_adapter_name(self, adapter: GhidraAdapter) -> None:
|
|
"""adapter field must be 'GhidraAdapter'."""
|
|
caps = adapter.capabilities()
|
|
assert caps["adapter"] == "GhidraAdapter"
|
|
|
|
def test_capabilities_concurrency(self, adapter: GhidraAdapter) -> None:
|
|
"""concurrency field must match PROJECT_SERIALIZED."""
|
|
caps = adapter.capabilities()
|
|
assert caps["concurrency"] == "PROJECT_SERIALIZED"
|
|
|
|
def test_capabilities_formats_is_list(self, adapter: GhidraAdapter) -> None:
|
|
"""formats must be a non-empty list of format strings."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["formats"], list)
|
|
assert len(caps["formats"]) > 0
|
|
assert all(isinstance(f, str) for f in caps["formats"])
|
|
|
|
def test_capabilities_architectures_is_list(self, adapter: GhidraAdapter) -> None:
|
|
"""architectures must be a non-empty list of architecture strings."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["architectures"], list)
|
|
assert len(caps["architectures"]) > 0
|
|
assert all(isinstance(a, str) for a in caps["architectures"])
|
|
|
|
def test_capabilities_profiles_is_list(self, adapter: GhidraAdapter) -> None:
|
|
"""profiles must be a non-empty list of profile dicts."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["profiles"], list)
|
|
assert len(caps["profiles"]) > 0
|
|
for profile in caps["profiles"]:
|
|
assert "name" in profile
|
|
assert "description" in profile
|
|
assert "analyser_count" in profile
|
|
|
|
def test_capabilities_profiles_include_standard_quick_deep(
|
|
self, adapter: GhidraAdapter
|
|
) -> None:
|
|
"""profiles must include standard, quick, and deep."""
|
|
caps = adapter.capabilities()
|
|
profile_names = {p["name"] for p in caps["profiles"]}
|
|
assert "standard" in profile_names
|
|
assert "quick" in profile_names
|
|
assert "deep" in profile_names
|
|
|
|
def test_capabilities_limitations_is_list(self, adapter: GhidraAdapter) -> None:
|
|
"""limitations must be a list of strings."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["limitations"], list)
|
|
assert len(caps["limitations"]) > 0
|
|
assert all(isinstance(item, str) for item in caps["limitations"])
|
|
|
|
def test_capabilities_pyghidra_available_is_bool(self, adapter: GhidraAdapter) -> None:
|
|
"""pyghidra_available must be a boolean."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["pyghidra_available"], bool)
|
|
|
|
def test_capabilities_jvm_initialized_is_bool(self, adapter: GhidraAdapter) -> None:
|
|
"""jvm_initialized must be a boolean."""
|
|
caps = adapter.capabilities()
|
|
assert isinstance(caps["jvm_initialized"], bool)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Available profiles
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAvailableProfiles:
|
|
"""Verify available_profiles() returns correct profiles."""
|
|
|
|
def test_available_profiles_returns_three(self, adapter: GhidraAdapter) -> None:
|
|
"""available_profiles() must return exactly 3 profiles."""
|
|
profiles = adapter.available_profiles()
|
|
assert len(profiles) == 3
|
|
|
|
def test_available_profiles_are_analysis_profiles(self, adapter: GhidraAdapter) -> None:
|
|
"""All returned profiles must be AnalysisProfile instances."""
|
|
profiles = adapter.available_profiles()
|
|
for p in profiles:
|
|
assert isinstance(p, AnalysisProfile)
|
|
|
|
def test_available_profiles_names(self, adapter: GhidraAdapter) -> None:
|
|
"""Profile names must be standard, quick, and deep."""
|
|
profiles = adapter.available_profiles()
|
|
names = {p.name for p in profiles}
|
|
assert names == {"standard", "quick", "deep"}
|
|
|
|
def test_available_profiles_have_analysers(self, adapter: GhidraAdapter) -> None:
|
|
"""Each profile must have a non-empty analysers list."""
|
|
profiles = adapter.available_profiles()
|
|
for p in profiles:
|
|
assert len(p.analysers) > 0, f"Profile {p.name} has no analysers"
|
|
|
|
def test_available_profiles_have_descriptions(self, adapter: GhidraAdapter) -> None:
|
|
"""Each profile must have a non-empty description."""
|
|
profiles = adapter.available_profiles()
|
|
for p in profiles:
|
|
assert p.description, f"Profile {p.name} has no description"
|
|
|
|
def test_available_profiles_standard_has_function_analysers(
|
|
self, adapter: GhidraAdapter
|
|
) -> None:
|
|
"""Standard profile must include function_start and function_id."""
|
|
for p in adapter.available_profiles():
|
|
if p.name == "standard":
|
|
assert "function_start" in p.analysers
|
|
assert "function_id" in p.analysers
|
|
break
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProfileValidation:
|
|
"""Verify validate_profile() (inherited from BackendAdapter)."""
|
|
|
|
def test_validate_known_profile_returns_profile(self, adapter: GhidraAdapter) -> None:
|
|
"""validate_profile with a known name must return the AnalysisProfile."""
|
|
profile = adapter.validate_profile("standard")
|
|
assert isinstance(profile, AnalysisProfile)
|
|
assert profile.name == "standard"
|
|
|
|
def test_validate_unknown_profile_raises_valueerror(self, adapter: GhidraAdapter) -> None:
|
|
"""validate_profile with an unknown name must raise ValueError."""
|
|
with pytest.raises(ValueError, match="Unknown analysis profile"):
|
|
adapter.validate_profile("nonexistent")
|
|
|
|
def test_validate_profile_error_lists_available(self, adapter: GhidraAdapter) -> None:
|
|
"""validate_profile error message must list available profiles."""
|
|
with pytest.raises(ValueError) as excinfo:
|
|
adapter.validate_profile("bogus")
|
|
error_msg = str(excinfo.value)
|
|
assert "standard" in error_msg
|
|
assert "quick" in error_msg
|
|
assert "deep" in error_msg
|
|
|
|
def test_validate_all_three_profiles_pass(self, adapter: GhidraAdapter) -> None:
|
|
"""validate_profile must accept standard, quick, and deep."""
|
|
for name in ("standard", "quick", "deep"):
|
|
profile = adapter.validate_profile(name)
|
|
assert profile.name == name
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Initialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInitialize:
|
|
"""Verify initialize() behavior."""
|
|
|
|
def test_initialize_raises_when_pyghidra_unavailable(self, adapter: GhidraAdapter) -> None:
|
|
"""initialize() must raise RuntimeError when PyGhidra not installed."""
|
|
# We don't have PyGhidra in the default test environment.
|
|
if not is_pyghidra_available():
|
|
with pytest.raises(RuntimeError, match="PyGhidra is not available"):
|
|
adapter.initialize()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skeleton methods raise NotImplementedError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkeletonMethods:
|
|
"""Verify skeleton methods raise NotImplementedError."""
|
|
|
|
_SKELETON_METHODS: ClassVar = [
|
|
("import_binary", ("/fake/path", Project(name="test")), {}),
|
|
("analyze", (Binary(), AnalysisProfile(name="standard")), {}),
|
|
("get_metadata", (Binary(),), {}),
|
|
("get_sections", (Binary(),), {}),
|
|
("get_entrypoints", (Binary(),), {}),
|
|
("get_imports", (Binary(),), {}),
|
|
("get_exports", (Binary(),), {}),
|
|
("get_symbols", (Binary(),), {}),
|
|
("get_strings", (Binary(),), {}),
|
|
("get_functions", (Binary(),), {}),
|
|
("decompile", (Binary(), Function(name="test")), {}),
|
|
(
|
|
"disassemble",
|
|
(
|
|
Binary(),
|
|
Address(space="ram", offset="0x1000", display="0x1000"),
|
|
Address(space="ram", offset="0x2000", display="0x2000"),
|
|
),
|
|
{},
|
|
),
|
|
(
|
|
"read_bytes",
|
|
(Binary(), Address(space="ram", offset="0x1000", display="0x1000"), 16),
|
|
{},
|
|
),
|
|
("get_xrefs", (Binary(), Address(space="ram", offset="0x1000", display="0x1000")), {}),
|
|
("get_callers", (Binary(), Function(name="test")), {}),
|
|
("get_callees", (Binary(), Function(name="test")), {}),
|
|
("get_callgraph", (Binary(), Function(name="test")), {}),
|
|
]
|
|
|
|
@pytest.mark.parametrize(
|
|
"method_name,args,kwargs",
|
|
_SKELETON_METHODS,
|
|
)
|
|
def test_skeleton_method_raises_not_implemented(
|
|
self,
|
|
adapter: GhidraAdapter,
|
|
method_name: str,
|
|
args: tuple,
|
|
kwargs: dict,
|
|
) -> None:
|
|
"""Skeleton method must raise NotImplementedError."""
|
|
method = getattr(adapter, method_name)
|
|
with pytest.raises(NotImplementedError):
|
|
method(*args, **kwargs)
|
|
|
|
def test_import_binary_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
|
|
"""NotImplementedError message must reference the deferred method."""
|
|
with pytest.raises(NotImplementedError, match="Ghidra binary import"):
|
|
adapter.import_binary("/test", Project(name="test"))
|
|
|
|
def test_decompile_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
|
|
"""NotImplementedError message must reference the deferred method."""
|
|
with pytest.raises(NotImplementedError, match="Ghidra decompile"):
|
|
adapter.decompile(Binary(), Function(name="test"))
|
|
|
|
def test_disassemble_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
|
|
"""NotImplementedError message must reference the deferred method."""
|
|
with pytest.raises(NotImplementedError, match="Ghidra disassembly"):
|
|
adapter.disassemble(
|
|
Binary(),
|
|
Address(space="ram", offset="0x1000", display="0x1000"),
|
|
Address(space="ram", offset="0x2000", display="0x2000"),
|
|
)
|
|
|
|
def test_get_callgraph_has_meaningful_message(self, adapter: GhidraAdapter) -> None:
|
|
"""NotImplementedError message must reference the deferred method."""
|
|
with pytest.raises(NotImplementedError, match="Ghidra callgraph"):
|
|
adapter.get_callgraph(Binary(), Function(name="test"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridge: Error normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestErrorNormalization:
|
|
"""Verify the bridge's error normalization maps Ghidra exceptions."""
|
|
|
|
def test_cancelled_exception_maps_to_timeout(self) -> None:
|
|
"""CancelledException must map to OperationTimeoutError."""
|
|
|
|
class CancelledException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(CancelledException("User cancelled"))
|
|
assert isinstance(err, OperationTimeoutError)
|
|
assert err.exit_code == ExitCode.OPERATION_TIMEOUT
|
|
|
|
def test_timeout_exception_maps_to_timeout(self) -> None:
|
|
"""TimeoutException must map to OperationTimeoutError."""
|
|
|
|
class TimeoutException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(TimeoutException("Timed out"))
|
|
assert isinstance(err, OperationTimeoutError)
|
|
assert err.exit_code == ExitCode.OPERATION_TIMEOUT
|
|
|
|
def test_unsupported_language_maps_to_unsupported_format(self) -> None:
|
|
"""UnsupportedLanguageException must map to UnsupportedFormatError."""
|
|
|
|
class UnsupportedLanguageException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(UnsupportedLanguageException("Bad arch"))
|
|
assert isinstance(err, UnsupportedFormatError)
|
|
assert err.exit_code == ExitCode.UNSUPPORTED_FORMAT
|
|
|
|
def test_domain_file_exception_maps_to_import_failed(self) -> None:
|
|
"""DomainFileException must map to ImportFailedError."""
|
|
|
|
class DomainFileException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(DomainFileException("Corrupt"))
|
|
assert isinstance(err, ImportFailedError)
|
|
assert err.exit_code == ExitCode.IMPORT_FAILED
|
|
|
|
def test_pe_exception_maps_to_import_failed(self) -> None:
|
|
"""PortableExecutableException must map to ImportFailedError."""
|
|
|
|
class PortableExecutableException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(PortableExecutableException("Bad PE"))
|
|
assert isinstance(err, ImportFailedError)
|
|
assert err.exit_code == ExitCode.IMPORT_FAILED
|
|
|
|
def test_elf_exception_maps_to_import_failed(self) -> None:
|
|
"""ELFException must map to ImportFailedError."""
|
|
|
|
class ELFException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(ELFException("Bad ELF"))
|
|
assert isinstance(err, ImportFailedError)
|
|
assert err.exit_code == ExitCode.IMPORT_FAILED
|
|
|
|
def test_mach_exception_maps_to_import_failed(self) -> None:
|
|
"""MachException must map to ImportFailedError."""
|
|
|
|
class MachException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(MachException("Bad Mach-O"))
|
|
assert isinstance(err, ImportFailedError)
|
|
assert err.exit_code == ExitCode.IMPORT_FAILED
|
|
|
|
def test_assert_exception_maps_to_analysis_failed(self) -> None:
|
|
"""AssertException must map to AnalysisFailedError."""
|
|
|
|
class AssertException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(AssertException("Assertion failed"))
|
|
assert isinstance(err, AnalysisFailedError)
|
|
assert err.exit_code == ExitCode.ANALYSIS_FAILED
|
|
|
|
def test_io_exception_maps_to_backend_failure(self) -> None:
|
|
"""IOException must map to BackendFailureError."""
|
|
|
|
class IOException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(IOException("Disk error"))
|
|
assert isinstance(err, BackendFailureError)
|
|
assert err.exit_code == ExitCode.BACKEND_FAILURE
|
|
|
|
def test_runtime_exception_maps_to_backend_failure(self) -> None:
|
|
"""RuntimeException must map to BackendFailureError."""
|
|
|
|
class RuntimeException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(RuntimeException("Unexpected"))
|
|
assert isinstance(err, BackendFailureError)
|
|
assert err.exit_code == ExitCode.BACKEND_FAILURE
|
|
|
|
def test_unknown_exception_maps_to_backend_failure(self) -> None:
|
|
"""Unrecognized exception must map to BackendFailureError (fallback)."""
|
|
|
|
class SomeObscureError(Exception):
|
|
pass
|
|
|
|
err = normalize_error(SomeObscureError("Mystery"))
|
|
assert isinstance(err, BackendFailureError)
|
|
assert err.exit_code == ExitCode.BACKEND_FAILURE
|
|
|
|
def test_backend_failure_preserves_original_error(self) -> None:
|
|
"""BackendFailureError must preserve the original error string."""
|
|
|
|
class IOException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(IOException("Disk full"))
|
|
assert isinstance(err, BackendFailureError)
|
|
assert err.original_error == "Disk full"
|
|
|
|
def test_operation_timeout_message_includes_original(self) -> None:
|
|
"""OperationTimeoutError message must reference the original cause."""
|
|
|
|
class CancelledException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(CancelledException("User hit cancel"))
|
|
assert isinstance(err, OperationTimeoutError)
|
|
assert "User hit cancel" in str(err)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridge: PyGhidra availability
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPyGhidraAvailability:
|
|
"""Verify bridge PyGhidra availability detection."""
|
|
|
|
def test_is_pyghidra_available_returns_bool(self) -> None:
|
|
"""is_pyghidra_available() must return a boolean."""
|
|
result = is_pyghidra_available()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_is_pyghidra_available_is_idempotent(self) -> None:
|
|
"""is_pyghidra_available() must return the same result on repeated calls."""
|
|
first = is_pyghidra_available()
|
|
second = is_pyghidra_available()
|
|
assert first == second
|
|
|
|
def test_is_initialized_returns_bool(self) -> None:
|
|
"""is_initialized() must return a boolean."""
|
|
result = is_initialized()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_ensure_initialized_returns_bool(self) -> None:
|
|
"""ensure_initialized() must return a boolean (False when PyGhidra unavailable)."""
|
|
result = ensure_initialized()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_get_ghidra_version_returns_string_or_none(self) -> None:
|
|
"""get_ghidra_version() must return a string or None."""
|
|
version = get_ghidra_version()
|
|
assert version is None or isinstance(version, str)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bridge: error normalization edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestErrorNormalizationEdgeCases:
|
|
"""Verify edge cases in error normalization."""
|
|
|
|
def test_empty_error_message(self) -> None:
|
|
"""Normalizing an exception with no message must still produce an error."""
|
|
|
|
class CancelledException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(CancelledException())
|
|
assert isinstance(err, OperationTimeoutError)
|
|
|
|
def test_nested_class_name_matching(self) -> None:
|
|
"""Exception names containing prefix substrings must match correctly."""
|
|
|
|
class GhidraCancelledException(Exception):
|
|
pass
|
|
|
|
err = normalize_error(GhidraCancelledException("Cancelled"))
|
|
assert isinstance(err, OperationTimeoutError)
|
|
|
|
def test_to_diagnostic_on_operation_timeout(self) -> None:
|
|
"""OperationTimeoutError.to_diagnostic() must include category and recoverable."""
|
|
err = OperationTimeoutError("Timed out")
|
|
diag = err.to_diagnostic()
|
|
assert diag["severity"] == "ERROR"
|
|
assert diag["category"] == "timeout"
|
|
assert diag["recoverable"] is True
|
|
|
|
def test_to_diagnostic_on_backend_failure(self) -> None:
|
|
"""BackendFailureError.to_diagnostic() must include backend_error."""
|
|
err = BackendFailureError("JVM crash", original_error="OOM")
|
|
diag = err.to_diagnostic()
|
|
assert diag["severity"] == "ERROR"
|
|
assert diag["backend_error"] == "OOM"
|