|
11 | 11 | import pytest |
12 | 12 | import typer |
13 | 13 |
|
14 | | -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies |
| 14 | +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, execute_commands |
15 | 15 | from cycode.cli.models import Document |
16 | 16 |
|
17 | 17 | _LOCK_FILE_NAME = 'generated.lock' |
@@ -68,6 +68,39 @@ def side_effect( |
68 | 68 | return side_effect |
69 | 69 |
|
70 | 70 |
|
| 71 | +class TestExecuteCommands: |
| 72 | + """Directly test the shell-failure sentinel handling in execute_commands.""" |
| 73 | + |
| 74 | + def test_returns_none_when_a_command_fails(self) -> None: |
| 75 | + """shell() returns None on non-zero exit; execute_commands must propagate None, not ''.""" |
| 76 | + with patch(f'{_BASE_MODULE}.shell', return_value=None): |
| 77 | + result = execute_commands([['poetry', 'lock']], timeout=30) |
| 78 | + |
| 79 | + assert result is None |
| 80 | + |
| 81 | + def test_stops_at_first_failing_command(self) -> None: |
| 82 | + """A failure in an earlier command short-circuits; later commands do not run.""" |
| 83 | + mock_shell = MagicMock(side_effect=[None, 'should-not-run']) |
| 84 | + with patch(f'{_BASE_MODULE}.shell', mock_shell): |
| 85 | + result = execute_commands([['a'], ['b']], timeout=30) |
| 86 | + |
| 87 | + assert result is None |
| 88 | + assert mock_shell.call_count == 1 |
| 89 | + |
| 90 | + def test_empty_output_success_is_not_treated_as_failure(self) -> None: |
| 91 | + """A successful command with empty stdout ('') must NOT be treated as a failure.""" |
| 92 | + with patch(f'{_BASE_MODULE}.shell', return_value=''): |
| 93 | + result = execute_commands([['poetry', 'lock']], timeout=30) |
| 94 | + |
| 95 | + assert result == '' |
| 96 | + |
| 97 | + def test_joins_successful_outputs(self) -> None: |
| 98 | + with patch(f'{_BASE_MODULE}.shell', side_effect=['out1', 'out2']): |
| 99 | + result = execute_commands([['a'], ['b']], timeout=30) |
| 100 | + |
| 101 | + assert result == 'out1\nout2' |
| 102 | + |
| 103 | + |
71 | 104 | class TestCleanupGeneratedFile: |
72 | 105 | def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: |
73 | 106 | doc = _make_doc(tmp_path) |
@@ -132,6 +165,24 @@ def test_failed_command_returns_none_and_no_file_created( |
132 | 165 | assert result is None |
133 | 166 | assert not lock_path.exists() |
134 | 167 |
|
| 168 | + def test_shell_failure_propagates_to_none_and_no_lockfile( |
| 169 | + self, handler: _MinimalRestoreHandler, tmp_path: Path |
| 170 | + ) -> None: |
| 171 | + """End-to-end failure path: shell() returns None (non-zero exit) -> restore returns None. |
| 172 | +
|
| 173 | + Regression for the stop-on-error bug: execute_commands must NOT swallow a failed |
| 174 | + command into an empty-string success. This exercises the real execute_commands with |
| 175 | + only shell() mocked (the layer where a non-zero exit is signalled as None). |
| 176 | + """ |
| 177 | + doc = _make_doc(tmp_path) |
| 178 | + lock_path = tmp_path / _LOCK_FILE_NAME |
| 179 | + |
| 180 | + with patch(f'{_BASE_MODULE}.shell', return_value=None): |
| 181 | + result = handler.try_restore_dependencies(doc) |
| 182 | + |
| 183 | + assert result is None, 'A failed restore command must return None so stop-on-error can fire' |
| 184 | + assert not lock_path.exists() |
| 185 | + |
135 | 186 | def test_generated_file_content_available_in_document_after_deletion( |
136 | 187 | self, handler: _MinimalRestoreHandler, tmp_path: Path |
137 | 188 | ) -> None: |
|
0 commit comments