Coverage for tests / unit / utils / test_node_deps.py: 100%
149 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for node_deps utilities."""
3from __future__ import annotations
5import subprocess
6from pathlib import Path
7from unittest.mock import MagicMock, patch
9import pytest
10from assertpy import assert_that
12from lintro.utils.node_deps import (
13 get_package_manager_command,
14 install_node_deps,
15 should_install_deps,
16)
18# =============================================================================
19# Tests for should_install_deps
20# =============================================================================
23def test_should_install_deps_returns_false_when_no_package_json(
24 tmp_path: Path,
25) -> None:
26 """Return False when package.json doesn't exist."""
27 result = should_install_deps(tmp_path)
29 assert_that(result).is_false()
32def test_should_install_deps_returns_true_when_package_json_exists_no_node_modules(
33 tmp_path: Path,
34) -> None:
35 """Return True when package.json exists but node_modules is missing."""
36 (tmp_path / "package.json").write_text("{}")
38 result = should_install_deps(tmp_path)
40 assert_that(result).is_true()
43def test_should_install_deps_returns_false_when_both_exist_with_content(
44 tmp_path: Path,
45) -> None:
46 """Return False when both package.json and node_modules exist with content."""
47 (tmp_path / "package.json").write_text("{}")
48 node_modules = tmp_path / "node_modules"
49 node_modules.mkdir()
50 # Add a real package directory (not just .bin)
51 (node_modules / "lodash").mkdir()
53 result = should_install_deps(tmp_path)
55 assert_that(result).is_false()
58def test_should_install_deps_returns_true_when_node_modules_empty(
59 tmp_path: Path,
60) -> None:
61 """Return True when node_modules exists but is empty."""
62 (tmp_path / "package.json").write_text("{}")
63 (tmp_path / "node_modules").mkdir()
65 result = should_install_deps(tmp_path)
67 assert_that(result).is_true()
70def test_should_install_deps_returns_true_when_node_modules_only_has_bin(
71 tmp_path: Path,
72) -> None:
73 """Return True when node_modules only contains .bin directory."""
74 (tmp_path / "package.json").write_text("{}")
75 node_modules = tmp_path / "node_modules"
76 node_modules.mkdir()
77 (node_modules / ".bin").mkdir()
79 result = should_install_deps(tmp_path)
81 assert_that(result).is_true()
84def test_should_install_deps_raises_permission_error_when_cwd_not_writable(
85 tmp_path: Path,
86) -> None:
87 """Raise PermissionError when package.json exists but directory is not writable."""
88 (tmp_path / "package.json").write_text("{}")
90 with (
91 patch("lintro.utils.node_deps.os.access", return_value=False),
92 pytest.raises(PermissionError, match="not writable"),
93 ):
94 should_install_deps(tmp_path)
97# =============================================================================
98# Tests for get_package_manager_command
99# =============================================================================
102def test_get_package_manager_command_returns_bun_when_available() -> None:
103 """Return bun install with --ignore-scripts when bun is available."""
104 with patch("lintro.utils.node_deps.shutil.which") as mock_which:
105 mock_which.side_effect = lambda x: "/usr/bin/bun" if x == "bun" else None
107 result = get_package_manager_command()
109 assert_that(result).is_equal_to(["bun", "install", "--ignore-scripts"])
112def test_get_package_manager_command_returns_npm_when_bun_not_available() -> None:
113 """Return npm install with --ignore-scripts when bun is not available but npm is."""
114 with patch("lintro.utils.node_deps.shutil.which") as mock_which:
115 mock_which.side_effect = lambda x: "/usr/bin/npm" if x == "npm" else None
117 result = get_package_manager_command()
119 assert_that(result).is_equal_to(["npm", "install", "--ignore-scripts"])
122def test_get_package_manager_command_returns_none_when_no_package_manager() -> None:
123 """Return None when no package manager is available."""
124 with patch("lintro.utils.node_deps.shutil.which", return_value=None):
125 result = get_package_manager_command()
127 assert_that(result).is_none()
130# =============================================================================
131# Tests for install_node_deps
132# =============================================================================
135def test_install_node_deps_returns_success_when_deps_already_installed(
136 tmp_path: Path,
137) -> None:
138 """Return success when dependencies are already installed."""
139 (tmp_path / "package.json").write_text("{}")
140 node_modules = tmp_path / "node_modules"
141 node_modules.mkdir()
142 (node_modules / "lodash").mkdir()
144 success, output = install_node_deps(tmp_path)
146 assert_that(success).is_true()
147 assert_that(output).contains("already installed")
150def test_install_node_deps_returns_failure_when_cwd_not_writable(
151 tmp_path: Path,
152) -> None:
153 """Return failure when directory is not writable (PermissionError from should_install_deps)."""
154 (tmp_path / "package.json").write_text("{}")
156 with patch("lintro.utils.node_deps.os.access", return_value=False):
157 success, output = install_node_deps(tmp_path)
159 assert_that(success).is_false()
160 assert_that(output).contains("not writable")
163def test_install_node_deps_returns_failure_when_no_package_manager(
164 tmp_path: Path,
165) -> None:
166 """Return failure when no package manager is available."""
167 (tmp_path / "package.json").write_text("{}")
169 with patch("lintro.utils.node_deps.shutil.which", return_value=None):
170 success, output = install_node_deps(tmp_path)
172 assert_that(success).is_false()
173 assert_that(output).contains("No package manager found")
176def test_install_node_deps_runs_bun_install_with_frozen_lockfile(
177 tmp_path: Path,
178) -> None:
179 """Try bun install with frozen lockfile first."""
180 (tmp_path / "package.json").write_text("{}")
182 mock_result = MagicMock()
183 mock_result.returncode = 0
184 mock_result.stdout = "Installed packages"
185 mock_result.stderr = ""
187 with (
188 patch(
189 "lintro.utils.node_deps.shutil.which",
190 side_effect=lambda x: "/usr/bin/bun" if x == "bun" else None,
191 ),
192 patch(
193 "lintro.utils.node_deps.subprocess.run",
194 return_value=mock_result,
195 ) as mock_run,
196 ):
197 success, output = install_node_deps(tmp_path)
199 assert_that(success).is_true()
200 # Verify frozen lockfile was attempted
201 call_args = mock_run.call_args_list[0]
202 assert_that(call_args[0][0]).contains("--frozen-lockfile")
205def test_install_node_deps_falls_back_to_regular_install_on_frozen_failure(
206 tmp_path: Path,
207) -> None:
208 """Fall back to regular install when frozen lockfile fails."""
209 (tmp_path / "package.json").write_text("{}")
211 frozen_result = MagicMock()
212 frozen_result.returncode = 1
213 frozen_result.stderr = "lockfile error"
215 regular_result = MagicMock()
216 regular_result.returncode = 0
217 regular_result.stdout = "Installed"
218 regular_result.stderr = ""
220 with (
221 patch(
222 "lintro.utils.node_deps.shutil.which",
223 side_effect=lambda x: "/usr/bin/bun" if x == "bun" else None,
224 ),
225 patch(
226 "lintro.utils.node_deps.subprocess.run",
227 side_effect=[frozen_result, regular_result],
228 ) as mock_run,
229 ):
230 success, output = install_node_deps(tmp_path)
232 assert_that(success).is_true()
233 # Verify both attempts were made
234 assert_that(mock_run.call_count).is_equal_to(2)
237def test_install_node_deps_returns_failure_on_install_error(
238 tmp_path: Path,
239) -> None:
240 """Return failure when both frozen and regular installation fail."""
241 (tmp_path / "package.json").write_text("{}")
243 failed_result = MagicMock()
244 failed_result.returncode = 1
245 failed_result.stdout = ""
246 failed_result.stderr = "npm ERR! network error"
248 with (
249 patch(
250 "lintro.utils.node_deps.shutil.which",
251 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None,
252 ),
253 patch(
254 "lintro.utils.node_deps.subprocess.run",
255 return_value=failed_result,
256 ) as mock_run,
257 ):
258 success, output = install_node_deps(tmp_path)
260 assert_that(success).is_false()
261 assert_that(output).contains("network error")
262 # Verify both npm ci and npm install were attempted
263 assert_that(mock_run.call_count).is_equal_to(2)
266def test_install_node_deps_retries_on_frozen_timeout(tmp_path: Path) -> None:
267 """Retry with regular install when frozen install times out."""
268 (tmp_path / "package.json").write_text("{}")
270 regular_result = MagicMock()
271 regular_result.returncode = 0
272 regular_result.stdout = "Installed"
273 regular_result.stderr = ""
275 with (
276 patch(
277 "lintro.utils.node_deps.shutil.which",
278 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None,
279 ),
280 patch(
281 "lintro.utils.node_deps.subprocess.run",
282 side_effect=[
283 subprocess.TimeoutExpired(cmd="npm ci", timeout=120),
284 regular_result,
285 ],
286 ) as mock_run,
287 ):
288 success, output = install_node_deps(tmp_path, timeout=120)
290 assert_that(success).is_true()
291 assert_that(mock_run.call_count).is_equal_to(2)
294def test_install_node_deps_fails_on_both_attempts_timeout(tmp_path: Path) -> None:
295 """Return failure when both frozen and regular install time out."""
296 (tmp_path / "package.json").write_text("{}")
298 with (
299 patch(
300 "lintro.utils.node_deps.shutil.which",
301 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None,
302 ),
303 patch(
304 "lintro.utils.node_deps.subprocess.run",
305 side_effect=subprocess.TimeoutExpired(cmd="npm", timeout=120),
306 ),
307 ):
308 success, output = install_node_deps(tmp_path, timeout=120)
310 assert_that(success).is_false()
311 assert_that(output).contains("timed out")
314def test_install_node_deps_uses_npm_ci_for_frozen_install(
315 tmp_path: Path,
316) -> None:
317 """Use npm ci for frozen install with npm."""
318 (tmp_path / "package.json").write_text("{}")
320 mock_result = MagicMock()
321 mock_result.returncode = 0
322 mock_result.stdout = "Installed"
323 mock_result.stderr = ""
325 with (
326 patch(
327 "lintro.utils.node_deps.shutil.which",
328 side_effect=lambda x: "/usr/bin/npm" if x == "npm" else None,
329 ),
330 patch(
331 "lintro.utils.node_deps.subprocess.run",
332 return_value=mock_result,
333 ) as mock_run,
334 ):
335 success, _ = install_node_deps(tmp_path)
337 assert_that(success).is_true()
338 # npm ci is the frozen lockfile equivalent for npm
339 # --ignore-scripts prevents lifecycle script execution for security
340 call_args = mock_run.call_args_list[0]
341 assert_that(call_args[0][0]).is_equal_to(["npm", "ci", "--ignore-scripts"])
344@pytest.mark.parametrize(
345 ("package_manager", "frozen_cmd", "regular_cmd"),
346 [
347 (
348 "bun",
349 ["bun", "install", "--ignore-scripts", "--frozen-lockfile"],
350 ["bun", "install", "--ignore-scripts"],
351 ),
352 (
353 "npm",
354 ["npm", "ci", "--ignore-scripts"],
355 ["npm", "install", "--ignore-scripts"],
356 ),
357 ],
358)
359def test_install_node_deps_uses_correct_commands_per_package_manager(
360 tmp_path: Path,
361 package_manager: str,
362 frozen_cmd: list[str],
363 regular_cmd: list[str],
364) -> None:
365 """Verify correct frozen and regular commands per package manager."""
366 (tmp_path / "package.json").write_text("{}")
368 # First call (frozen) fails, second call (regular) succeeds
369 frozen_result = MagicMock()
370 frozen_result.returncode = 1
371 frozen_result.stderr = "lockfile error"
373 regular_result = MagicMock()
374 regular_result.returncode = 0
375 regular_result.stdout = "Installed"
376 regular_result.stderr = ""
378 with (
379 patch(
380 "lintro.utils.node_deps.shutil.which",
381 side_effect=lambda x: (
382 f"/usr/bin/{package_manager}" if x == package_manager else None
383 ),
384 ),
385 patch(
386 "lintro.utils.node_deps.subprocess.run",
387 side_effect=[frozen_result, regular_result],
388 ) as mock_run,
389 ):
390 success, _ = install_node_deps(tmp_path)
392 assert_that(success).is_true()
393 assert_that(mock_run.call_count).is_equal_to(2)
394 # Verify frozen command was tried first
395 assert_that(mock_run.call_args_list[0][0][0]).is_equal_to(frozen_cmd)
396 # Verify regular command was used as fallback
397 assert_that(mock_run.call_args_list[1][0][0]).is_equal_to(regular_cmd)