ida-pro-mcp-xjoker 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ida_pro_mcp/__init__.py +0 -0
- ida_pro_mcp/__main__.py +6 -0
- ida_pro_mcp/ida_mcp/__init__.py +68 -0
- ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
- ida_pro_mcp/ida_mcp/api_core.py +337 -0
- ida_pro_mcp/ida_mcp/api_debug.py +617 -0
- ida_pro_mcp/ida_mcp/api_memory.py +304 -0
- ida_pro_mcp/ida_mcp/api_modify.py +406 -0
- ida_pro_mcp/ida_mcp/api_python.py +179 -0
- ida_pro_mcp/ida_mcp/api_resources.py +295 -0
- ida_pro_mcp/ida_mcp/api_stack.py +167 -0
- ida_pro_mcp/ida_mcp/api_types.py +480 -0
- ida_pro_mcp/ida_mcp/auth.py +166 -0
- ida_pro_mcp/ida_mcp/cache.py +232 -0
- ida_pro_mcp/ida_mcp/config.py +228 -0
- ida_pro_mcp/ida_mcp/framework.py +547 -0
- ida_pro_mcp/ida_mcp/http.py +859 -0
- ida_pro_mcp/ida_mcp/port_utils.py +104 -0
- ida_pro_mcp/ida_mcp/rpc.py +187 -0
- ida_pro_mcp/ida_mcp/server_manager.py +339 -0
- ida_pro_mcp/ida_mcp/sync.py +233 -0
- ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
- ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
- ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
- ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
- ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
- ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
- ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
- ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
- ida_pro_mcp/ida_mcp/ui.py +357 -0
- ida_pro_mcp/ida_mcp/utils.py +1186 -0
- ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
- ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
- ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
- ida_pro_mcp/ida_mcp.py +186 -0
- ida_pro_mcp/idalib_server.py +354 -0
- ida_pro_mcp/idalib_session_manager.py +259 -0
- ida_pro_mcp/server.py +1060 -0
- ida_pro_mcp/test.py +170 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""IDA Pro MCP Test Framework
|
|
2
|
+
|
|
3
|
+
This module provides a custom test framework for testing IDA MCP tools.
|
|
4
|
+
Tests can be defined inline or in separate test files using the @test decorator.
|
|
5
|
+
|
|
6
|
+
Usage from IDA console:
|
|
7
|
+
from ida_mcp.tests import run_tests
|
|
8
|
+
run_tests() # Run all tests
|
|
9
|
+
run_tests(category="api_core") # Run specific category
|
|
10
|
+
run_tests(pattern="*meta*") # Run tests matching pattern
|
|
11
|
+
|
|
12
|
+
Usage from command line:
|
|
13
|
+
ida-mcp-test tests/crackme03.elf
|
|
14
|
+
ida-mcp-test tests/crackme03.elf --category api_core
|
|
15
|
+
ida-mcp-test tests/crackme03.elf --pattern "*meta*"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import fnmatch
|
|
19
|
+
import time
|
|
20
|
+
import traceback
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Callable, Literal, Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# Test Registry
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TestInfo:
|
|
32
|
+
"""Information about a registered test."""
|
|
33
|
+
|
|
34
|
+
func: Callable
|
|
35
|
+
binary: str # Specific binary this test applies to
|
|
36
|
+
module: str # Auto-extracted category: "api_core", "api_analysis", etc.
|
|
37
|
+
skip: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Global test registry: name -> TestInfo
|
|
41
|
+
TESTS: dict[str, TestInfo] = {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test(*, binary: str = "", skip: bool = False) -> Callable:
|
|
45
|
+
"""Decorator to register a test function.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
binary: Name of the specific binary this test applies to
|
|
49
|
+
skip: If True, test will be skipped
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
@test()
|
|
53
|
+
def test_idb_meta():
|
|
54
|
+
meta = idb_meta()
|
|
55
|
+
assert_has_keys(meta, "path", "module")
|
|
56
|
+
|
|
57
|
+
@test(skip=True)
|
|
58
|
+
def test_broken_feature():
|
|
59
|
+
# This test is skipped
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@test(binary="crackme03.elf")
|
|
63
|
+
def test_crackme_specific():
|
|
64
|
+
# Only runs for crackme03.elf
|
|
65
|
+
pass
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def decorator(func: Callable) -> Callable:
|
|
69
|
+
# Extract module category from function's module name
|
|
70
|
+
# Handles both inline tests (api_core) and separate test files (test_api_core)
|
|
71
|
+
# e.g., "ida_pro_mcp.ida_mcp.api_core" -> "api_core"
|
|
72
|
+
# e.g., "ida_pro_mcp.ida_mcp.tests.test_api_core" -> "api_core"
|
|
73
|
+
module_name = func.__module__
|
|
74
|
+
if "." in module_name:
|
|
75
|
+
category = module_name.rsplit(".", 1)[-1]
|
|
76
|
+
else:
|
|
77
|
+
category = module_name
|
|
78
|
+
|
|
79
|
+
# Remove "test_" prefix if present (for separate test files)
|
|
80
|
+
if category.startswith("test_"):
|
|
81
|
+
category = category[5:]
|
|
82
|
+
|
|
83
|
+
# Register the test
|
|
84
|
+
TESTS[func.__name__] = TestInfo(
|
|
85
|
+
func=func,
|
|
86
|
+
binary=binary,
|
|
87
|
+
module=category,
|
|
88
|
+
skip=skip,
|
|
89
|
+
)
|
|
90
|
+
return func
|
|
91
|
+
|
|
92
|
+
return decorator
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ============================================================================
|
|
96
|
+
# Test Results
|
|
97
|
+
# ============================================================================
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class TestResult:
|
|
102
|
+
"""Result of a single test execution."""
|
|
103
|
+
|
|
104
|
+
name: str
|
|
105
|
+
category: str
|
|
106
|
+
status: Literal["passed", "failed", "skipped"]
|
|
107
|
+
duration: float = 0.0
|
|
108
|
+
error: Optional[str] = None
|
|
109
|
+
traceback: Optional[str] = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class TestResults:
|
|
114
|
+
"""Aggregate results of a test run."""
|
|
115
|
+
|
|
116
|
+
passed: int = 0
|
|
117
|
+
failed: int = 0
|
|
118
|
+
skipped: int = 0
|
|
119
|
+
total_time: float = 0.0
|
|
120
|
+
results: list[TestResult] = field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
def add(self, result: TestResult) -> None:
|
|
123
|
+
"""Add a test result and update counts."""
|
|
124
|
+
self.results.append(result)
|
|
125
|
+
if result.status == "passed":
|
|
126
|
+
self.passed += 1
|
|
127
|
+
elif result.status == "failed":
|
|
128
|
+
self.failed += 1
|
|
129
|
+
elif result.status == "skipped":
|
|
130
|
+
self.skipped += 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============================================================================
|
|
134
|
+
# Assertion Helpers
|
|
135
|
+
# ============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def assert_valid_address(addr: str) -> None:
|
|
139
|
+
"""Assert addr is a valid hex string starting with 0x."""
|
|
140
|
+
assert isinstance(addr, str), f"Expected string, got {type(addr).__name__}"
|
|
141
|
+
assert addr.startswith("0x") or addr.startswith("-0x"), (
|
|
142
|
+
f"Expected hex address, got {addr!r}"
|
|
143
|
+
)
|
|
144
|
+
# Verify it's a valid hex number
|
|
145
|
+
try:
|
|
146
|
+
int(addr, 16)
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise AssertionError(f"Invalid hex address: {addr!r}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def assert_has_keys(d: dict, *keys: str) -> None:
|
|
152
|
+
"""Assert dict has all specified keys."""
|
|
153
|
+
assert isinstance(d, dict), f"Expected dict, got {type(d).__name__}"
|
|
154
|
+
missing = [k for k in keys if k not in d]
|
|
155
|
+
assert not missing, f"Missing keys: {missing}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def assert_non_empty(value: Any) -> None:
|
|
159
|
+
"""Assert value is not None and not empty."""
|
|
160
|
+
assert value is not None, "Value is None"
|
|
161
|
+
if hasattr(value, "__len__"):
|
|
162
|
+
assert len(value) > 0, f"Value is empty: {value!r}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def assert_is_list(value: Any, min_length: int = 0) -> None:
|
|
166
|
+
"""Assert value is a list with at least min_length items."""
|
|
167
|
+
assert isinstance(value, list), f"Expected list, got {type(value).__name__}"
|
|
168
|
+
assert len(value) >= min_length, (
|
|
169
|
+
f"Expected at least {min_length} items, got {len(value)}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def assert_all_have_keys(items: list[dict], *keys: str) -> None:
|
|
174
|
+
"""Assert all dicts in list have specified keys."""
|
|
175
|
+
assert_is_list(items)
|
|
176
|
+
for i, item in enumerate(items):
|
|
177
|
+
assert isinstance(item, dict), f"Item {i} is not a dict: {type(item).__name__}"
|
|
178
|
+
missing = [k for k in keys if k not in item]
|
|
179
|
+
assert not missing, f"Item {i} missing keys: {missing}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ============================================================================
|
|
183
|
+
# Test Configuration
|
|
184
|
+
# ============================================================================
|
|
185
|
+
|
|
186
|
+
# Default sample size for deterministic sampling helpers
|
|
187
|
+
# Can be overridden by test runner via set_sample_size()
|
|
188
|
+
_sample_size: int = 5
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def set_sample_size(n: int) -> None:
|
|
192
|
+
"""Set the sample size for deterministic sampling helpers.
|
|
193
|
+
|
|
194
|
+
Called by test runner to configure how many items to sample.
|
|
195
|
+
"""
|
|
196
|
+
global _sample_size
|
|
197
|
+
_sample_size = max(1, n)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_sample_size() -> int:
|
|
201
|
+
"""Get the current sample size."""
|
|
202
|
+
return _sample_size
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ============================================================================
|
|
206
|
+
# Test Data Helpers
|
|
207
|
+
# ============================================================================
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_any_function() -> Optional[str]:
|
|
211
|
+
"""Returns address of first function, or None if no functions.
|
|
212
|
+
|
|
213
|
+
Must be called from within IDA context.
|
|
214
|
+
"""
|
|
215
|
+
import idautils
|
|
216
|
+
|
|
217
|
+
for ea in idautils.Functions():
|
|
218
|
+
return hex(ea)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_any_string() -> Optional[str]:
|
|
223
|
+
"""Returns address of first string, or None if no strings.
|
|
224
|
+
|
|
225
|
+
Must be called from within IDA context.
|
|
226
|
+
"""
|
|
227
|
+
import idaapi
|
|
228
|
+
|
|
229
|
+
for i in range(idaapi.get_strlist_qty()):
|
|
230
|
+
si = idaapi.string_info_t()
|
|
231
|
+
if idaapi.get_strlist_item(si, i):
|
|
232
|
+
return hex(si.ea)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_first_segment() -> Optional[tuple[str, str]]:
|
|
237
|
+
"""Returns (start_addr, end_addr) of first segment, or None.
|
|
238
|
+
|
|
239
|
+
Must be called from within IDA context.
|
|
240
|
+
"""
|
|
241
|
+
import idaapi
|
|
242
|
+
import idautils
|
|
243
|
+
|
|
244
|
+
for seg_ea in idautils.Segments():
|
|
245
|
+
seg = idaapi.getseg(seg_ea)
|
|
246
|
+
if seg:
|
|
247
|
+
return (hex(seg.start_ea), hex(seg.end_ea))
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _deterministic_sample(items: list, n: int) -> list:
|
|
252
|
+
"""Select n items deterministically based on binary name.
|
|
253
|
+
|
|
254
|
+
Uses hash of (binary_name, item_index) for reproducible but varied selection.
|
|
255
|
+
"""
|
|
256
|
+
import hashlib
|
|
257
|
+
|
|
258
|
+
if len(items) <= n:
|
|
259
|
+
return items
|
|
260
|
+
|
|
261
|
+
binary_name = get_current_binary_name()
|
|
262
|
+
|
|
263
|
+
# Create (hash, item) pairs for sorting
|
|
264
|
+
def item_hash(idx: int) -> int:
|
|
265
|
+
key = f"{binary_name}:{idx}".encode()
|
|
266
|
+
return int(hashlib.md5(key).hexdigest(), 16)
|
|
267
|
+
|
|
268
|
+
indexed = [(item_hash(i), item) for i, item in enumerate(items)]
|
|
269
|
+
indexed.sort(key=lambda x: x[0])
|
|
270
|
+
|
|
271
|
+
return [item for _, item in indexed[:n]]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_n_functions(n: Optional[int] = None) -> list[str]:
|
|
275
|
+
"""Get N function addresses, deterministically selected.
|
|
276
|
+
|
|
277
|
+
Selection is based on binary name for reproducibility across runs.
|
|
278
|
+
Returns up to N addresses (fewer if binary has fewer functions).
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
n: Number of functions to return. Defaults to global sample_size.
|
|
282
|
+
"""
|
|
283
|
+
import idautils
|
|
284
|
+
|
|
285
|
+
if n is None:
|
|
286
|
+
n = _sample_size
|
|
287
|
+
|
|
288
|
+
all_funcs = [hex(ea) for ea in idautils.Functions()]
|
|
289
|
+
return _deterministic_sample(all_funcs, n)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_n_strings(n: Optional[int] = None) -> list[str]:
|
|
293
|
+
"""Get N string addresses, deterministically selected.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
n: Number of strings to return. Defaults to global sample_size.
|
|
297
|
+
"""
|
|
298
|
+
import idaapi
|
|
299
|
+
|
|
300
|
+
if n is None:
|
|
301
|
+
n = _sample_size
|
|
302
|
+
|
|
303
|
+
all_strings = []
|
|
304
|
+
for i in range(idaapi.get_strlist_qty()):
|
|
305
|
+
si = idaapi.string_info_t()
|
|
306
|
+
if idaapi.get_strlist_item(si, i):
|
|
307
|
+
all_strings.append(hex(si.ea))
|
|
308
|
+
|
|
309
|
+
return _deterministic_sample(all_strings, n)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_data_address() -> Optional[str]:
|
|
313
|
+
"""Get an address in a data segment (not code).
|
|
314
|
+
|
|
315
|
+
Useful for testing error paths when code address is expected.
|
|
316
|
+
"""
|
|
317
|
+
import idaapi
|
|
318
|
+
import idautils
|
|
319
|
+
|
|
320
|
+
for seg_ea in idautils.Segments():
|
|
321
|
+
seg = idaapi.getseg(seg_ea)
|
|
322
|
+
if seg and not (seg.perm & idaapi.SEGPERM_EXEC):
|
|
323
|
+
# Return first address in non-executable segment
|
|
324
|
+
return hex(seg.start_ea)
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_unmapped_address() -> str:
|
|
329
|
+
"""Get an address that is not mapped in the binary.
|
|
330
|
+
|
|
331
|
+
Useful for testing error paths for invalid addresses.
|
|
332
|
+
"""
|
|
333
|
+
return "0xDEADBEEFDEADBEEF"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_functions_with_calls() -> list[str]:
|
|
337
|
+
"""Get functions that contain call instructions (have callees).
|
|
338
|
+
|
|
339
|
+
Useful for testing callees() on functions that actually call others.
|
|
340
|
+
"""
|
|
341
|
+
import idaapi
|
|
342
|
+
import idautils
|
|
343
|
+
|
|
344
|
+
result = []
|
|
345
|
+
for func_ea in idautils.Functions():
|
|
346
|
+
func = idaapi.get_func(func_ea)
|
|
347
|
+
if not func:
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
# Check if function contains any call instructions
|
|
351
|
+
has_call = False
|
|
352
|
+
for head in idautils.Heads(func.start_ea, func.end_ea):
|
|
353
|
+
insn = idaapi.insn_t()
|
|
354
|
+
if idaapi.decode_insn(insn, head) > 0:
|
|
355
|
+
if insn.itype in [idaapi.NN_call, idaapi.NN_callfi, idaapi.NN_callni]:
|
|
356
|
+
has_call = True
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
if has_call:
|
|
360
|
+
result.append(hex(func_ea))
|
|
361
|
+
|
|
362
|
+
return _deterministic_sample(result, _sample_size)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_functions_with_callers() -> list[str]:
|
|
366
|
+
"""Get functions that are called by other functions (have callers).
|
|
367
|
+
|
|
368
|
+
Useful for testing callers() on functions that are actually called.
|
|
369
|
+
"""
|
|
370
|
+
import idaapi
|
|
371
|
+
import idautils
|
|
372
|
+
|
|
373
|
+
result = []
|
|
374
|
+
for func_ea in idautils.Functions():
|
|
375
|
+
# Check if this function has any code references to it
|
|
376
|
+
has_caller = False
|
|
377
|
+
for xref in idautils.XrefsTo(func_ea, 0):
|
|
378
|
+
if xref.iscode:
|
|
379
|
+
caller_func = idaapi.get_func(xref.frm)
|
|
380
|
+
if caller_func and caller_func.start_ea != func_ea:
|
|
381
|
+
has_caller = True
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
if has_caller:
|
|
385
|
+
result.append(hex(func_ea))
|
|
386
|
+
|
|
387
|
+
return _deterministic_sample(result, _sample_size)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ============================================================================
|
|
391
|
+
# Test Runner
|
|
392
|
+
# ============================================================================
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def get_current_binary_name() -> str:
|
|
396
|
+
"""Get the name of the currently loaded binary.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
The filename of the current IDB (e.g., "crackme03.elf")
|
|
400
|
+
"""
|
|
401
|
+
import idaapi
|
|
402
|
+
|
|
403
|
+
return idaapi.get_root_filename()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def run_tests(
|
|
407
|
+
pattern: str = "*",
|
|
408
|
+
category: str = "*",
|
|
409
|
+
verbose: bool = True,
|
|
410
|
+
stop_on_failure: bool = False,
|
|
411
|
+
) -> TestResults:
|
|
412
|
+
"""Run registered tests and return results.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
pattern: Glob pattern to filter test names (e.g., "*meta*")
|
|
416
|
+
category: Filter by module category (e.g., "api_core", "api_analysis")
|
|
417
|
+
verbose: Print progress and results
|
|
418
|
+
stop_on_failure: Stop at first failure
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
TestResults with pass/fail counts and individual results
|
|
422
|
+
"""
|
|
423
|
+
results = TestResults()
|
|
424
|
+
start_time = time.time()
|
|
425
|
+
|
|
426
|
+
# Get current binary name for filtering binary-specific tests
|
|
427
|
+
current_binary = get_current_binary_name()
|
|
428
|
+
|
|
429
|
+
# Group tests by category
|
|
430
|
+
tests_by_category: dict[str, list[tuple[str, TestInfo]]] = {}
|
|
431
|
+
for name, info in sorted(TESTS.items()):
|
|
432
|
+
# Filter by pattern
|
|
433
|
+
if not fnmatch.fnmatch(name, pattern):
|
|
434
|
+
continue
|
|
435
|
+
# Filter by category
|
|
436
|
+
if category != "*" and info.module != category:
|
|
437
|
+
continue
|
|
438
|
+
# Filter by binary - skip tests for other binaries
|
|
439
|
+
if info.binary and info.binary != current_binary:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
if info.module not in tests_by_category:
|
|
443
|
+
tests_by_category[info.module] = []
|
|
444
|
+
tests_by_category[info.module].append((name, info))
|
|
445
|
+
|
|
446
|
+
if not tests_by_category:
|
|
447
|
+
if verbose:
|
|
448
|
+
print(f"No tests found matching pattern={pattern!r}, category={category!r}")
|
|
449
|
+
return results
|
|
450
|
+
|
|
451
|
+
# Print header
|
|
452
|
+
if verbose:
|
|
453
|
+
print("=" * 80)
|
|
454
|
+
print("IDA Pro MCP Test Runner")
|
|
455
|
+
print("=" * 80)
|
|
456
|
+
print()
|
|
457
|
+
|
|
458
|
+
# Run tests by category
|
|
459
|
+
for cat_name in sorted(tests_by_category.keys()):
|
|
460
|
+
tests = tests_by_category[cat_name]
|
|
461
|
+
if verbose:
|
|
462
|
+
print(f"[{cat_name}] Running {len(tests)} tests...")
|
|
463
|
+
|
|
464
|
+
for name, info in tests:
|
|
465
|
+
result = _run_single_test(name, info, verbose)
|
|
466
|
+
results.add(result)
|
|
467
|
+
|
|
468
|
+
if result.status == "failed" and stop_on_failure:
|
|
469
|
+
if verbose:
|
|
470
|
+
print()
|
|
471
|
+
print("Stopping on first failure.")
|
|
472
|
+
break
|
|
473
|
+
|
|
474
|
+
if stop_on_failure and results.failed > 0:
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
if verbose:
|
|
478
|
+
print()
|
|
479
|
+
|
|
480
|
+
results.total_time = time.time() - start_time
|
|
481
|
+
|
|
482
|
+
# Print summary
|
|
483
|
+
if verbose:
|
|
484
|
+
print("=" * 80)
|
|
485
|
+
status_parts = []
|
|
486
|
+
if results.passed:
|
|
487
|
+
status_parts.append(f"{results.passed} passed")
|
|
488
|
+
if results.failed:
|
|
489
|
+
status_parts.append(f"{results.failed} failed")
|
|
490
|
+
if results.skipped:
|
|
491
|
+
status_parts.append(f"{results.skipped} skipped")
|
|
492
|
+
print(f"Results: {', '.join(status_parts)} ({results.total_time:.2f}s)")
|
|
493
|
+
print("=" * 80)
|
|
494
|
+
|
|
495
|
+
return results
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _run_single_test(name: str, info: TestInfo, verbose: bool) -> TestResult:
|
|
499
|
+
"""Run a single test and return the result."""
|
|
500
|
+
# Handle skipped tests
|
|
501
|
+
if info.skip:
|
|
502
|
+
if verbose:
|
|
503
|
+
print(f" - {name} (skipped)")
|
|
504
|
+
return TestResult(
|
|
505
|
+
name=name,
|
|
506
|
+
category=info.module,
|
|
507
|
+
status="skipped",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Run the test
|
|
511
|
+
start_time = time.time()
|
|
512
|
+
try:
|
|
513
|
+
info.func()
|
|
514
|
+
duration = time.time() - start_time
|
|
515
|
+
|
|
516
|
+
if verbose:
|
|
517
|
+
print(f" + {name} ({duration:.2f}s)")
|
|
518
|
+
|
|
519
|
+
return TestResult(
|
|
520
|
+
name=name,
|
|
521
|
+
category=info.module,
|
|
522
|
+
status="passed",
|
|
523
|
+
duration=duration,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
except Exception as e:
|
|
527
|
+
duration = time.time() - start_time
|
|
528
|
+
error_msg = str(e)
|
|
529
|
+
tb = traceback.format_exc()
|
|
530
|
+
|
|
531
|
+
if verbose:
|
|
532
|
+
print(f" x {name} ({duration:.2f}s)")
|
|
533
|
+
print(f" {type(e).__name__}: {error_msg}")
|
|
534
|
+
print()
|
|
535
|
+
# Indent traceback
|
|
536
|
+
for line in tb.strip().split("\n"):
|
|
537
|
+
print(f" {line}")
|
|
538
|
+
print()
|
|
539
|
+
|
|
540
|
+
return TestResult(
|
|
541
|
+
name=name,
|
|
542
|
+
category=info.module,
|
|
543
|
+
status="failed",
|
|
544
|
+
duration=duration,
|
|
545
|
+
error=f"{type(e).__name__}: {error_msg}",
|
|
546
|
+
traceback=tb,
|
|
547
|
+
)
|