Coverage for tests / unit / core / test_version_requirements.py: 98%

144 statements  

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

1"""Tests for version requirements functionality.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7from typing import TYPE_CHECKING 

8from unittest.mock import MagicMock, patch 

9 

10import pytest 

11from assertpy import assert_that 

12from packaging.version import Version 

13 

14if TYPE_CHECKING: 

15 pass 

16 

17from lintro._tool_versions import TOOL_VERSIONS, get_min_version, get_tool_version 

18from lintro.enums.tool_name import ToolName, normalize_tool_name 

19from lintro.tools.core.version_parsing import ( 

20 ToolVersionInfo, 

21 check_tool_version, 

22 compare_versions, 

23 extract_version_from_output, 

24 get_install_hints, 

25 get_minimum_versions, 

26 parse_version, 

27) 

28from lintro.tools.core.version_requirements import get_all_tool_versions 

29 

30 

31@pytest.mark.parametrize( 

32 "version_str,expected", 

33 [ 

34 ("1.2.3", Version("1.2.3")), 

35 ("0.14.0", Version("0.14.0")), 

36 ("2.0", Version("2.0")), 

37 ("1.0.0-alpha", Version("1.0.0")), # Pre-release suffix stripped 

38 ("v1.5.0", Version("1.5.0")), # Handle leading 'v' 

39 ], 

40) 

41def test_parse_version(version_str: str, expected: Version) -> None: 

42 """Test version string parsing using packaging.version. 

43 

44 Args: 

45 version_str: Version string to parse. 

46 expected: Expected parsed Version object. 

47 """ 

48 assert_that(parse_version(version_str)).is_equal_to(expected) 

49 

50 

51def test_parse_version_invalid() -> None: 

52 """Test that parse_version raises ValueError for invalid input.""" 

53 with pytest.raises(ValueError, match="Unable to parse version string"): 

54 parse_version("invalid") 

55 

56 

57@pytest.mark.parametrize( 

58 "version1,version2,expected", 

59 [ 

60 ("1.2.3", "1.2.3", 0), # Equal 

61 ("1.2.3", "1.2.4", -1), # version1 < version2 

62 ("1.3.0", "1.2.4", 1), # version1 > version2 

63 ("2.0.0", "1.9.9", 1), # Major version difference 

64 ("1.10.0", "1.2.0", 1), # Minor version difference 

65 ], 

66) 

67def test_compare_versions(version1: str, version2: str, expected: int) -> None: 

68 """Test version comparison. 

69 

70 Args: 

71 version1: First version string to compare. 

72 version2: Second version string to compare. 

73 expected: Expected comparison result (-1, 0, or 1). 

74 """ 

75 assert_that(compare_versions(version1, version2)).is_equal_to(expected) 

76 

77 

78@pytest.mark.parametrize( 

79 "tool_name,output,expected", 

80 [ 

81 ("black", "black, 25.9.0 (compiled: yes)", "25.9.0"), 

82 ("bandit", "__main__.py 1.8.6", "1.8.6"), 

83 ("hadolint", "Haskell Dockerfile Linter 2.14.0", "2.14.0"), 

84 ("actionlint", "actionlint 1.7.5", "1.7.5"), 

85 ("pydoclint", "pydoclint 0.5.9", "0.5.9"), 

86 ("semgrep", "semgrep 1.148.0", "1.148.0"), 

87 ("ruff", "ruff 0.14.4", "0.14.4"), 

88 ("yamllint", "yamllint 1.37.1", "1.37.1"), 

89 # Astro: actual output has "v" prefix and double space 

90 ("astro-check", "astro v5.5.3", "5.5.3"), 

91 # Vue-tsc: simple version output 

92 ("vue-tsc", "2.2.4", "2.2.4"), 

93 # Clippy: rustc output should extract Rust version directly 

94 ("clippy", "rustc 1.92.0 (ded5c06cf 2025-12-08)", "1.92.0"), 

95 # Clippy: clippy output should convert 0.1.X to 1.X.0 

96 ("clippy", "clippy 0.1.92 (ded5c06cf2 2025-12-08)", "1.92.0"), 

97 ("clippy", "clippy 0.1.75 (abcdef123 2024-01-01)", "1.75.0"), 

98 ], 

99) 

100def test_extract_version_from_output( 

101 tool_name: str, 

102 output: str, 

103 expected: str, 

104) -> None: 

105 """Test version extraction from various tool outputs. 

106 

107 Args: 

108 tool_name: Name of the tool. 

109 output: Raw version output string from tool. 

110 expected: Expected extracted version string. 

111 """ 

112 assert_that(extract_version_from_output(output, tool_name)).is_equal_to(expected) 

113 

114 

115def test_get_minimum_versions_from_tool_versions() -> None: 

116 """Test reading minimum versions from _tool_versions.py.""" 

117 versions = get_minimum_versions() 

118 

119 # Should include external tools from _tool_versions.py 

120 assert_that(versions).contains_key("hadolint") 

121 assert_that(versions).contains_key("actionlint") 

122 assert_that(versions).contains_key("pytest") 

123 assert_that(versions).contains_key("semgrep") 

124 

125 # Versions should be strings 

126 assert_that(versions["hadolint"]).is_instance_of(str) 

127 assert_that(versions["actionlint"]).is_instance_of(str) 

128 

129 

130@pytest.mark.parametrize( 

131 "tool_name", 

132 [ 

133 ToolName.ACTIONLINT, 

134 ToolName.HADOLINT, 

135 ToolName.OXLINT, 

136 ToolName.PRETTIER, 

137 ToolName.SEMGREP, 

138 ToolName.SHELLCHECK, 

139 ToolName.TSC, 

140 ], 

141) 

142def test_get_min_version_returns_version_for_registered_tools( 

143 tool_name: ToolName, 

144) -> None: 

145 """Test that get_min_version returns version for registered tools. 

146 

147 Args: 

148 tool_name: ToolName enum member to test. 

149 """ 

150 version = get_min_version(tool_name) 

151 assert_that(version).is_instance_of(str) 

152 assert_that(version).matches(r"^\d+\.\d+") # Starts with X.Y 

153 

154 

155def test_get_min_version_raises_keyerror_for_unknown_tool() -> None: 

156 """Test that get_min_version raises KeyError for unknown tools.""" 

157 with pytest.raises(KeyError, match="not found"): 

158 get_min_version("nonexistent_tool") # type: ignore[arg-type] 

159 

160 

161def test_get_tool_version_supports_companion_packages() -> None: 

162 """Companion npm packages should resolve via get_tool_version.""" 

163 repo_root = Path(__file__).resolve().parents[3] 

164 package_json = json.loads((repo_root / "package.json").read_text()) 

165 expected = package_json["devDependencies"]["@astrojs/check"].lstrip("^~") 

166 

167 version = get_tool_version("@astrojs/check") 

168 

169 assert_that(version).is_equal_to(expected) 

170 

171 

172def test_tool_versions_uses_toolname_enum_keys() -> None: 

173 """Test that TOOL_VERSIONS uses ToolName enum as keys.""" 

174 for key in TOOL_VERSIONS: 

175 assert_that(key).is_instance_of(ToolName) 

176 

177 

178def test_all_external_tools_registered_in_tool_versions() -> None: 

179 """Test that all expected external tools have versions available. 

180 

181 npm-managed tools (markdownlint, oxfmt, oxlint, prettier, tsc) are 

182 read from package.json at runtime. Non-npm tools are in TOOL_VERSIONS. 

183 """ 

184 from lintro._tool_versions import get_all_expected_versions 

185 

186 repo_root = Path(__file__).resolve().parents[3] 

187 manifest_path = repo_root / "lintro" / "tools" / "manifest.json" 

188 

189 # Non-npm tools should be in TOOL_VERSIONS 

190 expected_non_npm_tools = { 

191 ToolName.ACTIONLINT, 

192 ToolName.CARGO_AUDIT, 

193 ToolName.CLIPPY, 

194 ToolName.GITLEAKS, 

195 ToolName.HADOLINT, 

196 ToolName.PYTEST, 

197 ToolName.RUSTC, 

198 ToolName.RUSTFMT, 

199 ToolName.SEMGREP, 

200 ToolName.SHELLCHECK, 

201 ToolName.SHFMT, 

202 ToolName.SQLFLUFF, 

203 ToolName.TAPLO, 

204 } 

205 if manifest_path.exists(): 

206 manifest = json.loads(manifest_path.read_text()) 

207 manifest_tools = { 

208 normalize_tool_name(tool["name"]) 

209 for tool in manifest.get("tools", []) 

210 if isinstance(tool, dict) and tool.get("name") 

211 } 

212 assert_that(set(TOOL_VERSIONS.keys())).is_subset_of(manifest_tools) 

213 else: 

214 assert_that(set(TOOL_VERSIONS.keys())).is_equal_to(expected_non_npm_tools) 

215 

216 # All tools (including npm-managed) should be available via get_all_expected_versions 

217 all_versions = get_all_expected_versions() 

218 if manifest_path.exists(): 

219 assert_that(set(all_versions.keys())).is_equal_to(manifest_tools) 

220 else: 

221 expected_all_tools = { 

222 ToolName.ACTIONLINT, 

223 ToolName.ASTRO_CHECK, 

224 ToolName.CARGO_AUDIT, 

225 ToolName.CLIPPY, 

226 ToolName.GITLEAKS, 

227 ToolName.HADOLINT, 

228 ToolName.MARKDOWNLINT, 

229 ToolName.OXFMT, 

230 ToolName.OXLINT, 

231 ToolName.PRETTIER, 

232 ToolName.PYTEST, 

233 ToolName.RUSTC, 

234 ToolName.RUSTFMT, 

235 ToolName.SEMGREP, 

236 ToolName.SHELLCHECK, 

237 ToolName.SHFMT, 

238 ToolName.SQLFLUFF, 

239 ToolName.SVELTE_CHECK, 

240 ToolName.TAPLO, 

241 ToolName.TSC, 

242 ToolName.VUE_TSC, 

243 } 

244 assert_that(set(all_versions.keys())).is_equal_to(expected_all_tools) 

245 

246 

247def test_get_tool_version_returns_version_for_toolname_enum() -> None: 

248 """Test that get_tool_version works with ToolName enum.""" 

249 version = get_tool_version(ToolName.TSC) 

250 assert_that(version).is_not_none() 

251 assert_that(version).is_instance_of(str) 

252 

253 

254def test_get_tool_version_typescript_alias_resolves_to_tsc() -> None: 

255 """Test that 'typescript' alias resolves to TSC version. 

256 

257 This is important for shell script compatibility where the npm 

258 package name 'typescript' needs to resolve to the tsc version. 

259 """ 

260 typescript_version = get_tool_version("typescript") 

261 tsc_version = get_tool_version(ToolName.TSC) 

262 assert_that(typescript_version).is_equal_to(tsc_version) 

263 assert_that(typescript_version).is_not_none() 

264 

265 

266def test_get_tool_version_returns_none_for_unknown_tool() -> None: 

267 """Test that get_tool_version returns None for unknown tools.""" 

268 version = get_tool_version("nonexistent_tool") 

269 assert_that(version).is_none() 

270 

271 

272def test_get_install_hints() -> None: 

273 """Test generating install hints.""" 

274 hints = get_install_hints() 

275 

276 assert_that(hints).contains_key("pytest") 

277 assert_that(hints).contains_key("markdownlint") 

278 assert_that(hints["pytest"]).contains("Install via:") 

279 assert_that(hints["markdownlint"]).contains("bun add") 

280 

281 

282def test_version_caching() -> None: 

283 """Test that versions are cached properly.""" 

284 # First call 

285 versions1 = get_minimum_versions() 

286 hints1 = get_install_hints() 

287 

288 # Second call should return equal values (cached) 

289 versions2 = get_minimum_versions() 

290 hints2 = get_install_hints() 

291 

292 assert_that(versions1).is_equal_to(versions2) 

293 assert_that(hints1).is_equal_to(hints2) 

294 

295 

296@patch("subprocess.run") 

297def test_check_tool_version_success(mock_run: MagicMock) -> None: 

298 """Test successful version check. 

299 

300 Args: 

301 mock_run: Mocked subprocess.run function. 

302 """ 

303 min_hadolint = get_minimum_versions()["hadolint"] 

304 mock_run.return_value = type( 

305 "MockResult", 

306 (), 

307 { 

308 "returncode": 0, 

309 "stdout": f"Haskell Dockerfile Linter {min_hadolint}", 

310 "stderr": "", 

311 }, 

312 )() 

313 

314 result = check_tool_version("hadolint", ["hadolint"]) 

315 

316 assert_that(result.name).is_equal_to("hadolint") 

317 assert_that(result.current_version).is_equal_to(min_hadolint) 

318 assert_that(result.min_version).is_equal_to(min_hadolint) 

319 assert_that(result.version_check_passed).is_true() 

320 assert_that(result.error_message).is_none() 

321 

322 

323@patch("subprocess.run") 

324def test_check_tool_version_hyphenated_alias_uses_requirements( 

325 mock_run: MagicMock, 

326) -> None: 

327 """Hyphenated tool names should resolve to canonical version requirements.""" 

328 min_astro = get_minimum_versions()["astro_check"] 

329 mock_run.return_value = type( 

330 "MockResult", 

331 (), 

332 { 

333 "returncode": 0, 

334 "stdout": f"astro {min_astro}", 

335 "stderr": "", 

336 }, 

337 )() 

338 

339 result = check_tool_version("astro-check", ["bunx", "astro"]) 

340 

341 assert_that(result.name).is_equal_to("astro-check") 

342 assert_that(result.current_version).is_equal_to(min_astro) 

343 assert_that(result.min_version).is_equal_to(min_astro) 

344 assert_that(result.install_hint).contains("astro") 

345 assert_that(result.version_check_passed).is_true() 

346 

347 

348@patch("subprocess.run") 

349def test_check_tool_version_failure(mock_run: MagicMock) -> None: 

350 """Test version check that fails due to old version. 

351 

352 Args: 

353 mock_run: Mocked subprocess.run function. 

354 """ 

355 min_hadolint = get_minimum_versions()["hadolint"] 

356 mock_run.return_value = type( 

357 "MockResult", 

358 (), 

359 { 

360 "returncode": 0, 

361 "stdout": "Haskell Dockerfile Linter 0.0.0", # Always below any real minimum 

362 "stderr": "", 

363 }, 

364 )() 

365 

366 result = check_tool_version("hadolint", ["hadolint"]) 

367 

368 assert_that(result.name).is_equal_to("hadolint") 

369 assert_that(result.current_version).is_equal_to("0.0.0") 

370 assert_that(result.min_version).is_equal_to(min_hadolint) 

371 assert_that(result.version_check_passed).is_false() 

372 assert_that(result.error_message).contains("below minimum requirement") 

373 

374 

375@patch("subprocess.run") 

376def test_check_tool_version_command_failure(mock_run: MagicMock) -> None: 

377 """Test version check when command fails. 

378 

379 Args: 

380 mock_run: Mocked subprocess.run function. 

381 """ 

382 mock_run.side_effect = FileNotFoundError("Command not found") 

383 

384 result = check_tool_version("nonexistent", ["nonexistent"]) 

385 

386 assert_that(result.name).is_equal_to("nonexistent") 

387 assert_that(result.current_version).is_none() 

388 # For tools not in requirements, version check passes (no enforcement) 

389 assert_that(result.version_check_passed).is_true() 

390 assert_that(result.error_message).is_not_none() 

391 assert_that(result.error_message).contains("Failed to run version check") 

392 

393 

394def test_tool_version_info_creation() -> None: 

395 """Test ToolVersionInfo dataclass.""" 

396 info = ToolVersionInfo( 

397 name="test_tool", 

398 min_version="1.0.0", 

399 install_hint="Install test_tool", 

400 current_version="1.2.0", 

401 version_check_passed=True, 

402 ) 

403 

404 assert_that(info.name).is_equal_to("test_tool") 

405 assert_that(info.current_version).is_equal_to("1.2.0") 

406 assert_that(info.version_check_passed).is_true() 

407 

408 

409@patch("subprocess.run") 

410def test_get_all_tool_versions(mock_run: MagicMock) -> None: 

411 """Test getting versions for all tools. 

412 

413 Args: 

414 mock_run: Mocked subprocess.run function. 

415 """ 

416 # Mock successful version checks for all tools 

417 mock_run.return_value = type( 

418 "MockResult", 

419 (), 

420 { 

421 "returncode": 0, 

422 "stdout": "0.14.4", # Generic version response 

423 "stderr": "", 

424 }, 

425 )() 

426 

427 results = get_all_tool_versions() 

428 

429 # Dynamically build expected set from registry (same source of truth) 

430 from lintro.tools.core.tool_registry import ToolRegistry 

431 

432 registry = ToolRegistry.load() 

433 expected_tools = { 

434 tool.name 

435 for tool in registry.all_tools(include_dev=True) 

436 if tool.version_command 

437 } 

438 

439 assert_that(set(results.keys())).is_equal_to(expected_tools) 

440 

441 # Each result should be a ToolVersionInfo 

442 for tool_name, info in results.items(): 

443 assert_that(info).is_instance_of(ToolVersionInfo) 

444 assert_that(info.name).is_equal_to(tool_name)