Coverage for src / ida_pro_mcp / test.py: 6%

80 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-07 00:26 +0000

1"""Standalone test runner for IDA Pro MCP using idalib. 

2 

3Usage: 

4 ida-mcp-test tests/crackme03.elf 

5 ida-mcp-test tests/crackme03.elf --category api_core 

6 ida-mcp-test tests/crackme03.elf --pattern "*meta*" 

7 

8With coverage: 

9 uv run coverage run -m ida_pro_mcp.test crackme03.elf 

10 uv run coverage report 

11 uv run coverage html 

12""" 

13 

14import os 

15import sys 

16import argparse 

17from pathlib import Path 

18 

19# idapro must go first to initialize idalib 

20import idapro 

21import ida_auto 

22 

23 

24def main() -> int: 

25 """Entry point for ida-mcp-test command.""" 

26 parser = argparse.ArgumentParser( 

27 description="Run IDA Pro MCP tests using idalib", 

28 formatter_class=argparse.RawDescriptionHelpFormatter, 

29 epilog=""" 

30Examples: 

31 ida-mcp-test tests/crackme03.elf 

32 ida-mcp-test tests/crackme03.elf --category api_core 

33 ida-mcp-test tests/crackme03.elf --pattern "*meta*" 

34 ida-mcp-test tests/crackme03.elf --stop-on-failure 

35 

36With coverage: 

37 uv run coverage run -m ida_pro_mcp.test crackme03.elf 

38 uv run coverage report --show-missing 

39 uv run coverage html && open htmlcov/index.html 

40 """, 

41 ) 

42 parser.add_argument("binary", type=Path, help="Path to binary file to analyze") 

43 parser.add_argument( 

44 "--pattern", 

45 "-p", 

46 default="*", 

47 help="Glob pattern to filter test names (default: *)", 

48 ) 

49 parser.add_argument( 

50 "--category", 

51 "-c", 

52 default="*", 

53 help="Filter by module category (default: *)", 

54 ) 

55 parser.add_argument( 

56 "--stop-on-failure", 

57 "-x", 

58 action="store_true", 

59 help="Stop at first failure", 

60 ) 

61 parser.add_argument( 

62 "--quiet", 

63 "-q", 

64 action="store_true", 

65 help="Quiet mode - only show summary", 

66 ) 

67 parser.add_argument( 

68 "--list", 

69 "-l", 

70 action="store_true", 

71 help="List available tests without running them", 

72 ) 

73 parser.add_argument( 

74 "--verbose", 

75 "-v", 

76 action="store_true", 

77 help="Show IDA console messages", 

78 ) 

79 args = parser.parse_args() 

80 

81 # Check binary exists 

82 if not args.binary.exists(): 

83 print(f"Error: Binary not found: {args.binary}", file=sys.stderr) 

84 return 1 

85 

86 # Configure IDA console output 

87 if args.verbose: 

88 idapro.enable_console_messages(True) 

89 else: 

90 idapro.enable_console_messages(False) 

91 

92 # Open database 

93 print(f"Opening database for: {args.binary}") 

94 if idapro.open_database(str(args.binary), run_auto_analysis=True): 

95 print("Error: Failed to open database", file=sys.stderr) 

96 return 1 

97 

98 # Wait for auto-analysis 

99 print("Waiting for auto-analysis...") 

100 ida_auto.auto_wait() 

101 print() 

102 

103 try: 

104 # Import test framework AFTER idalib is initialized 

105 from ida_pro_mcp.ida_mcp.framework import run_tests, TESTS 

106 

107 # Import all test modules to register @test decorators. 

108 # Use pkgutil discovery so registration works even if tests package 

109 # __init__ does not eagerly import submodules. 

110 import importlib 

111 import pkgutil 

112 

113 tests_pkg_name = "ida_pro_mcp.ida_mcp.tests" 

114 tests_pkg = importlib.import_module(tests_pkg_name) 

115 if hasattr(tests_pkg, "__path__"): 

116 for mod in pkgutil.iter_modules(tests_pkg.__path__): 

117 if mod.name.startswith("test_"): 

118 importlib.import_module(f"{tests_pkg_name}.{mod.name}") 

119 

120 if not TESTS: 

121 print( 

122 "Warning: no tests were registered from ida_pro_mcp.ida_mcp.tests", 

123 file=sys.stderr, 

124 ) 

125 

126 # Handle --list 

127 if args.list: 

128 print("Available tests:") 

129 by_category: dict[str, list[str]] = {} 

130 for name, info in sorted(TESTS.items()): 

131 if info.module not in by_category: 

132 by_category[info.module] = [] 

133 by_category[info.module].append(name) 

134 

135 for cat in sorted(by_category.keys()): 

136 print(f"\n[{cat}]") 

137 for name in by_category[cat]: 

138 info = TESTS[name] 

139 skip_marker = " (skip)" if info.skip else "" 

140 print(f" {name}{skip_marker}") 

141 return 0 

142 

143 # Run tests 

144 in_ci = os.environ.get("CI", "").lower() not in ("", "0", "false", "no") 

145 interactive_output = sys.stdout.isatty() 

146 show_all_test_output = (not args.quiet) and (interactive_output or in_ci) 

147 results = run_tests( 

148 pattern=args.pattern, 

149 category=args.category, 

150 verbose=show_all_test_output, 

151 stop_on_failure=args.stop_on_failure, 

152 failures_only=(not args.quiet) and not show_all_test_output, 

153 ) 

154 

155 # No matched tests is likely a configuration/test-selection mistake 

156 if not results.results: 

157 print( 

158 "Error: no tests matched the requested pattern/category", 

159 file=sys.stderr, 

160 ) 

161 return 1 

162 

163 # In quiet mode, print summary 

164 if args.quiet: 

165 status_parts = [] 

166 if results.passed: 

167 status_parts.append(f"{results.passed} passed") 

168 if results.failed: 

169 status_parts.append(f"{results.failed} failed") 

170 if results.skipped: 

171 status_parts.append(f"{results.skipped} skipped") 

172 print(f"Results: {', '.join(status_parts)} ({results.total_time:.2f}s)") 

173 

174 if results.failed: 

175 print("\nFailed tests:") 

176 for r in results.results: 

177 if r.status == "failed": 

178 print(f" {r.name}: {r.error}") 

179 

180 return 1 if results.failed > 0 else 0 

181 

182 finally: 

183 # Close database 

184 idapro.close_database() 

185 

186 

187if __name__ == "__main__": 

188 sys.exit(main())