"""Installer execution, verification, and error-path tests for `specify self upgrade`.""" import errno import subprocess from unittest.mock import patch from specify_cli import app from tests.self_upgrade_helpers import ( _completed_process, mock_urlopen_response, requires_posix, runner, strip_ansi, ) # =========================================================================== # Phase 6 — User Story 4: failure recovery (P2) # =========================================================================== class TestInstallerMissing: """Installer disappeared between detection and run → exit 3.""" def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ): which_results = {"specify": "/usr/local/bin/specify"} with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) assert "Installer uv not found on PATH; reinstall it and retry." in out assert "Upgrading specify-cli" not in out def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ): which_results = {} with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n) ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert "Installer pipx not found on PATH" in strip_ansi(result.output) def test_absolute_installer_path_does_not_require_path_lookup( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o755) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch( "specify_cli._version._verify_upgrade", return_value="0.7.6" ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(0)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 @requires_posix def test_relative_installer_path_does_not_require_path_lookup( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "uv" fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o755) monkeypatch.chdir(tmp_path) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch( "specify_cli._version._verify_upgrade", return_value="0.7.6" ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ "./uv", "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(0)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 assert mock_run.call_args.args[0][0] == "./uv" @requires_posix def test_relative_installer_path_missing_gets_path_specific_message( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): monkeypatch.chdir(tmp_path) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ "./uv", "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( "Installer path ./uv no longer exists; reinstall it and retry." in strip_ansi(result.output) ) assert "not found on PATH" not in strip_ansi(result.output) def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o755) def fake_run(argv, *args, **kwargs): fake_uv.unlink() raise FileNotFoundError(str(fake_uv)) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: str(fake_uv) if name == "uv" else None, ), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( f"Installer path {fake_uv} no longer exists; reinstall it and retry." in strip_ansi(result.output) ) def test_absolute_installer_path_not_executable_gets_specific_message( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o644) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.os.access", return_value=False), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry." in strip_ansi(result.output) ) @requires_posix def test_relative_installer_path_not_executable_gets_path_specific_message( self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "uv" fake_uv.write_text("#!/bin/sh\n") fake_uv.chmod(0o644) monkeypatch.chdir(tmp_path) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.os.access", return_value=False), patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ "./uv", "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) out = strip_ansi(result.output) assert result.exit_code == 3 assert ( "Installer path ./uv is not an executable file; fix the path or reinstall it and retry." in out ) assert "Installer ./uv is not executable" not in out def test_real_installer_exit_126_is_not_treated_as_invalid_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(126)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 126 out = strip_ansi(result.output) assert "Upgrade failed. Installer exit code: 126." in out assert "not an executable file" not in out def test_absolute_installer_path_missing_gets_path_specific_message( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "missing-installer" / "uv" with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 assert ( f"Installer path {fake_uv} no longer exists; reinstall it and retry." in strip_ansi(result.output) ) mock_run.assert_not_called() def test_exec_oserror_is_treated_as_invalid_installer( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch( "specify_cli._version.subprocess.run", side_effect=PermissionError("Permission denied"), ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) assert f"Installer path {fake_uv} is not an executable file" in out assert "not found on PATH" not in out def test_bare_invalid_installer_message_does_not_call_it_a_path( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ "uv", "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch( "specify_cli._version.subprocess.run", side_effect=PermissionError("Permission denied"), ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) assert "Installer uv is not executable" in out assert "Installer path uv" not in out def test_exec_oserror_errno_is_treated_as_invalid_installer( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) invalid_error = OSError(errno.ENOEXEC, "Exec format error") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch("specify_cli._version.subprocess.run", side_effect=invalid_error): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 3 out = strip_ansi(result.output) assert f"Installer path {fake_uv} is not an executable file" in out assert "not found on PATH" not in out def test_transient_exec_oserror_is_not_treated_as_invalid_installer( self, uv_tool_argv0, clean_environ, tmp_path ): fake_uv = tmp_path / "installer-bin" / "uv" fake_uv.parent.mkdir() fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8") fake_uv.chmod(0o755) transient_error = OSError(errno.EMFILE, "Too many open files") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", side_effect=lambda name: None ), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch( "specify_cli._version._assemble_installer_argv", return_value=[ str(fake_uv), "tool", "install", "specify-cli", "--force", "--from", "git+https://github.com/github/spec-kit.git@v0.7.6", ], ), patch("specify_cli._version.subprocess.run", side_effect=transient_error): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) result = runner.invoke(app, ["self", "upgrade"]) # Transient/unknown OSErrors are re-raised rather than mapped to the # invalid-installer exit 3, so the CLI surfaces them as an uncaught # error: exit code 1 with the original OSError preserved. assert result.exit_code == 1 assert isinstance(result.exception, OSError) class TestInstallerFailed: """Installer non-zero exit → propagate code, print rollback hint.""" def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] # installer fails result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Upgrade failed. Installer exit code: 2." in out assert "Try again or run the command manually:" in out assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out assert ( "To pin back to the previous version: " "uv tool install specify-cli --force --from " "git+https://github.com/github/spec-kit.git@v0.7.5" ) in out # No verification attempted after a failed installer run. assert mock_run.call_count == 1 def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(127)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 127 def test_installer_timeout_prints_timeout_specific_message( self, uv_tool_argv0, clean_environ, monkeypatch ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ subprocess.TimeoutExpired(cmd=["uv"], timeout=12) ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 124 out = strip_ansi(result.output) assert "Upgrade timed out while waiting for the installer subprocess." in out assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out def test_non_finite_timeout_warns_and_runs_without_timeout( self, uv_tool_argv0, clean_environ, monkeypatch ): monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan") with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [ _completed_process(0), _completed_process(0, stdout="specify 0.7.6\n"), ] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 0 assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi( result.output ) assert mock_run.call_args_list[0].kwargs["timeout"] is None def test_real_installer_exit_124_is_not_treated_as_timeout( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(124)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 124 out = strip_ansi(result.output) assert "Upgrade failed. Installer exit code: 124." in out assert "Upgrade timed out while waiting for the installer subprocess." not in out def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="pipx" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert ( "To pin back to the previous version: pipx install --force " "git+https://github.com/github/spec-kit.git@v0.7.5" ) in out def test_rollback_hint_accepts_normalizable_stable_snapshot( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="v0.7.5" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"}) mock_run.side_effect = [_completed_process(2)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert ( "To pin back to the previous version: uv tool install specify-cli --force " "--from git+https://github.com/github/spec-kit.git@v0.7.5" ) in out assert "Previous version was not an exact stable release tag" not in out def test_prerelease_failure_degrades_rollback_hint_to_releases_page( self, uv_tool_argv0, clean_environ ): with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( "specify_cli._version.shutil.which", return_value="uv" ), patch("specify_cli._version.subprocess.run") as mock_run, patch( "specify_cli._version._get_installed_version", return_value="1.0.0rc1" ): mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"}) mock_run.side_effect = [_completed_process(2)] result = runner.invoke(app, ["self", "upgrade"]) assert result.exit_code == 2 out = strip_ansi(result.output) assert "Previous version was not an exact stable release tag" in out assert "https://github.com/github/spec-kit/releases" in out assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out