543 lines
25 KiB
Python
543 lines
25 KiB
Python
"""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
|