Coverage for tests / unit / tools / executor / test_tool_executor.py: 99%

107 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for main tool executor: success/failure and JSON outputs.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from dataclasses import dataclass, field 

7from pathlib import Path 

8from typing import Any, Never 

9 

10import pytest 

11from assertpy import assert_that 

12 

13import lintro.utils.tool_executor as te 

14from lintro.models.core.tool_result import ToolResult 

15from lintro.tools import tool_manager 

16from lintro.utils.execution.tool_configuration import ToolsToRunResult 

17from lintro.utils.output import OutputManager 

18from lintro.utils.tool_executor import run_lint_tools_simple 

19 

20 

21@dataclass 

22class FakeToolDefinition: 

23 """Fake ToolDefinition for testing.""" 

24 

25 name: str 

26 can_fix: bool = False 

27 description: str = "" 

28 file_patterns: list[str] = field(default_factory=list) 

29 native_configs: list[str] = field(default_factory=list) 

30 

31 

32class FakeTool: 

33 """Simple tool stub returning a pre-baked ToolResult.""" 

34 

35 def __init__(self, name: str, can_fix: bool, result: ToolResult) -> None: 

36 """Initialize the fake tool. 

37 

38 Args: 

39 name: Tool name. 

40 can_fix: Whether fixes are supported. 

41 result: Result object to return from check/fix. 

42 """ 

43 self.name = name 

44 self._definition = FakeToolDefinition(name=name, can_fix=can_fix) 

45 self._result = result 

46 self.options: dict[str, Any] = {} 

47 

48 @property 

49 def definition(self) -> FakeToolDefinition: 

50 """Return the tool definition. 

51 

52 Returns: 

53 FakeToolDefinition containing tool metadata. 

54 """ 

55 return self._definition 

56 

57 @property 

58 def can_fix(self) -> bool: 

59 """Return whether the tool can fix issues. 

60 

61 Returns: 

62 True if the tool can fix issues. 

63 """ 

64 return self._definition.can_fix 

65 

66 def reset_options(self) -> None: 

67 """Reset options to defaults (stub for testing).""" 

68 self.options = {} 

69 

70 def set_options(self, **kwargs: Any) -> None: 

71 """Record option values provided to the tool stub. 

72 

73 Args: 

74 **kwargs: Arbitrary options to store for assertions. 

75 """ 

76 self.options.update(kwargs) 

77 

78 def check( 

79 self, 

80 paths: list[str], 

81 options: dict[str, Any] | None = None, 

82 ) -> ToolResult: 

83 """Return the stored result for a check invocation. 

84 

85 Args: 

86 paths: Target paths (ignored in stub). 

87 options: Optional tool options. 

88 

89 Returns: 

90 ToolResult: Pre-baked result instance. 

91 """ 

92 return self._result 

93 

94 def fix( 

95 self, 

96 paths: list[str], 

97 options: dict[str, Any] | None = None, 

98 ) -> ToolResult: 

99 """Return the stored result for a fix invocation. 

100 

101 Args: 

102 paths: Target paths (ignored in stub). 

103 options: Optional tool options. 

104 

105 Returns: 

106 ToolResult: Pre-baked result instance. 

107 """ 

108 return self._result 

109 

110 

111def _setup_tool_manager( 

112 monkeypatch: pytest.MonkeyPatch, 

113 tools: dict[str, FakeTool], 

114) -> None: 

115 """Configure tool manager stubs to return provided tools. 

116 

117 Args: 

118 monkeypatch: Pytest monkeypatch fixture. 

119 tools: Mapping of tool name to FakeTool instance. 

120 """ 

121 tools_dict = tools 

122 

123 def fake_get_tools( 

124 tools: str | None, 

125 action: str, 

126 **_kwargs: object, 

127 ) -> ToolsToRunResult: 

128 return ToolsToRunResult(to_run=list(tools_dict.keys())) 

129 

130 monkeypatch.setattr(te, "get_tools_to_run", fake_get_tools, raising=True) 

131 

132 def fake_get_tool(name: str) -> FakeTool: 

133 return tools_dict[name.lower()] 

134 

135 monkeypatch.setattr(tool_manager, "get_tool", fake_get_tool, raising=True) 

136 

137 def noop_write_reports_from_results( 

138 self: Any, 

139 results: list[ToolResult], 

140 ) -> None: 

141 return None 

142 

143 monkeypatch.setattr( 

144 OutputManager, 

145 "write_reports_from_results", 

146 noop_write_reports_from_results, 

147 raising=True, 

148 ) 

149 

150 

151def _stub_logger(monkeypatch: pytest.MonkeyPatch, fake_logger: Any) -> None: 

152 """Patch create_logger to return a FakeLogger instance. 

153 

154 Args: 

155 monkeypatch: Pytest monkeypatch fixture. 

156 fake_logger: FakeLogger fixture instance. 

157 """ 

158 import lintro.utils.console as cl 

159 

160 monkeypatch.setattr(cl, "create_logger", lambda **k: fake_logger, raising=True) 

161 

162 

163def test_executor_check_success( 

164 monkeypatch: pytest.MonkeyPatch, 

165 fake_logger: Any, 

166) -> None: 

167 """Exit with 0 when check succeeds and has zero issues. 

168 

169 Args: 

170 monkeypatch: Pytest monkeypatch fixture. 

171 fake_logger: FakeLogger fixture. 

172 """ 

173 _stub_logger(monkeypatch, fake_logger) 

174 result = ToolResult(name="ruff", success=True, output="", issues_count=0) 

175 _setup_tool_manager( 

176 monkeypatch, 

177 {"ruff": FakeTool("ruff", can_fix=True, result=result)}, 

178 ) 

179 code = run_lint_tools_simple( 

180 action="check", 

181 paths=["."], 

182 tools="all", 

183 tool_options=None, 

184 exclude=None, 

185 include_venv=False, 

186 group_by="auto", 

187 output_format="grid", 

188 verbose=False, 

189 raw_output=False, 

190 ) 

191 assert_that(code).is_equal_to(0) 

192 

193 

194def test_executor_check_failure( 

195 monkeypatch: pytest.MonkeyPatch, 

196 fake_logger: Any, 

197) -> None: 

198 """Exit with 1 when check succeeds but issues are reported. 

199 

200 Args: 

201 monkeypatch: Pytest monkeypatch fixture. 

202 fake_logger: FakeLogger fixture. 

203 """ 

204 _stub_logger(monkeypatch, fake_logger) 

205 result = ToolResult(name="ruff", success=True, output="something", issues_count=2) 

206 _setup_tool_manager( 

207 monkeypatch, 

208 {"ruff": FakeTool("ruff", can_fix=True, result=result)}, 

209 ) 

210 code = run_lint_tools_simple( 

211 action="check", 

212 paths=["."], 

213 tools="all", 

214 tool_options=None, 

215 exclude=None, 

216 include_venv=False, 

217 group_by="file", 

218 output_format="grid", 

219 verbose=True, 

220 raw_output=False, 

221 ) 

222 assert_that(code).is_equal_to(1) 

223 

224 

225def test_executor_fmt_success_with_counts( 

226 monkeypatch: pytest.MonkeyPatch, 

227 fake_logger: Any, 

228) -> None: 

229 """Exit with 0 when format succeeds and counts are populated. 

230 

231 Args: 

232 monkeypatch: Pytest monkeypatch fixture. 

233 fake_logger: Fake logger fixture. 

234 """ 

235 _stub_logger(monkeypatch, fake_logger) 

236 result = ToolResult( 

237 name="prettier", 

238 success=True, 

239 output="Fixed 2 issue(s)\nFound 0 issue(s) that cannot be auto-fixed", 

240 issues_count=0, 

241 fixed_issues_count=2, 

242 remaining_issues_count=0, 

243 ) 

244 _setup_tool_manager( 

245 monkeypatch, 

246 {"prettier": FakeTool("prettier", can_fix=True, result=result)}, 

247 ) 

248 code = run_lint_tools_simple( 

249 action="fmt", 

250 paths=["."], 

251 tools="all", 

252 tool_options=None, 

253 exclude=None, 

254 include_venv=False, 

255 group_by="auto", 

256 output_format="grid", 

257 verbose=False, 

258 raw_output=False, 

259 ) 

260 assert_that(code).is_equal_to(0) 

261 

262 

263def test_executor_json_output( 

264 monkeypatch: pytest.MonkeyPatch, 

265 fake_logger: Any, 

266 capsys: pytest.CaptureFixture[str], 

267) -> None: 

268 """Emit JSON output containing action and results when requested. 

269 

270 Args: 

271 monkeypatch: Pytest monkeypatch fixture. 

272 fake_logger: Fake logger fixture. 

273 capsys: Pytest capture fixture for stdout. 

274 """ 

275 _stub_logger(monkeypatch, fake_logger) 

276 t1 = ToolResult(name="ruff", success=True, output="", issues_count=1) 

277 t2 = ToolResult( 

278 name="prettier", 

279 success=True, 

280 output="", 

281 issues_count=0, 

282 fixed_issues_count=1, 

283 remaining_issues_count=0, 

284 ) 

285 _setup_tool_manager( 

286 monkeypatch, 

287 { 

288 "ruff": FakeTool("ruff", can_fix=True, result=t1), 

289 "prettier": FakeTool("prettier", can_fix=True, result=t2), 

290 }, 

291 ) 

292 code = run_lint_tools_simple( 

293 action="check", 

294 paths=["."], 

295 tools="all", 

296 tool_options=None, 

297 exclude=None, 

298 include_venv=False, 

299 group_by="auto", 

300 output_format="json", 

301 verbose=False, 

302 raw_output=False, 

303 ) 

304 assert_that(code).is_equal_to(1) 

305 out = capsys.readouterr().out 

306 data = json.loads(out) 

307 # JSON output includes results and summary sections 

308 assert_that("results" in data and len(data["results"]) >= 2).is_true() 

309 assert_that("summary" in data).is_true() 

310 

311 

312def test_executor_handles_tool_failure_with_output( 

313 monkeypatch: pytest.MonkeyPatch, 

314 fake_logger: Any, 

315 tmp_path: Path, 

316) -> None: 

317 """Return non-zero when a tool fails but emits output (coverage branch). 

318 

319 Args: 

320 monkeypatch: pytest monkeypatch fixture 

321 fake_logger: Fake logger fixture. 

322 tmp_path: pytest tmp_path fixture 

323 """ 

324 _stub_logger(monkeypatch, fake_logger) 

325 failing = ToolResult(name="bandit", success=False, output="oops", issues_count=0) 

326 _setup_tool_manager( 

327 monkeypatch, 

328 {"bandit": FakeTool("bandit", can_fix=False, result=failing)}, 

329 ) 

330 code = run_lint_tools_simple( 

331 action="check", 

332 paths=[str(tmp_path)], 

333 tools="all", 

334 tool_options=None, 

335 exclude=None, 

336 include_venv=False, 

337 group_by="auto", 

338 output_format="plain", 

339 verbose=False, 

340 raw_output=False, 

341 ) 

342 assert_that(code).is_equal_to(1) 

343 

344 

345def test_parse_tool_options_typed_values() -> None: 

346 """Ensure --tool-options parsing coerces values into proper types. 

347 

348 Supported coercions: 

349 - booleans (True/False) 

350 - None/null 

351 - integers/floats 

352 - lists via pipe separation (E|F|W) 

353 """ 

354 opt_str = ( 

355 "ruff:unsafe_fixes=True,ruff:line_length=88,ruff:target_version=py313," 

356 "ruff:select=E|F,prettier:verbose_fix_output=false,yamllint:strict=None," 

357 "ruff:ratio=0.5" 

358 ) 

359 from lintro.utils.tool_options import parse_tool_options 

360 

361 parsed = parse_tool_options(opt_str) 

362 assert_that( 

363 isinstance(parsed["ruff"]["unsafe_fixes"], bool) 

364 and parsed["ruff"]["unsafe_fixes"], 

365 ).is_true() 

366 assert_that( 

367 isinstance(parsed["ruff"]["line_length"], int) 

368 and parsed["ruff"]["line_length"] == 88, 

369 ).is_true() 

370 assert_that(parsed["ruff"]["target_version"]).is_equal_to("py313") 

371 assert_that(parsed["ruff"]["select"]).is_equal_to(["E", "F"]) 

372 assert_that(parsed["prettier"]["verbose_fix_output"] is False).is_true() 

373 assert_that(parsed["yamllint"]["strict"]).is_none() 

374 assert_that( 

375 isinstance(parsed["ruff"]["ratio"], float) and parsed["ruff"]["ratio"] == 0.5, 

376 ).is_true() 

377 

378 

379def test_executor_unknown_tool( 

380 monkeypatch: pytest.MonkeyPatch, 

381 fake_logger: Any, 

382) -> None: 

383 """Exit with 1 when an unknown tool is requested. 

384 

385 Args: 

386 monkeypatch: Pytest monkeypatch fixture. 

387 fake_logger: Fake logger fixture. 

388 """ 

389 _stub_logger(monkeypatch, fake_logger) 

390 

391 def raise_value_error(_tools: str | None, _action: str, **_kwargs: object) -> Never: 

392 raise ValueError("unknown tool") 

393 

394 monkeypatch.setattr(te, "get_tools_to_run", raise_value_error, raising=True) 

395 code = run_lint_tools_simple( 

396 action="check", 

397 paths=["."], 

398 tools="unknown", 

399 tool_options=None, 

400 exclude=None, 

401 include_venv=False, 

402 group_by="auto", 

403 output_format="grid", 

404 verbose=False, 

405 raw_output=False, 

406 ) 

407 assert_that(code).is_equal_to(1)