mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat(cli): implement specify self upgrade (#2475)
* feat(cli): implement specify self upgrade
* fix(cli): normalize self-upgrade prerelease tags
* fix(cli): tighten self-upgrade diagnostics
* fix(cli): harden self-upgrade verification parsing
* fix(cli): sanitize self-check fallback tags
* fix(cli): harden self-check release display
* fix(cli): validate resolved upgrade tags
* fix(cli): tolerate invalid install metadata
* test(cli): align upgrade network mocks
* fix(cli): respect relative installer paths
* fix(cli): tighten upgrade failure handling
* fix(cli): align installer path diagnostics
* fix(cli): validate release and version output
* fix(cli): clarify source checkout guidance
* fix(cli): harden upgrade detection helpers
* fix(cli): avoid echoing invalid release tags
* fix(cli): tolerate argv path resolve failures
* chore: remove self-upgrade formatting-only diffs
* fix: address self-upgrade review feedback
* fix: address self-upgrade review followups
* fix: address self-upgrade review edge cases
* fix: address self-upgrade review docs
* fix: refine self-upgrade review followups
* fix: address self-upgrade review cleanup
* fix: handle self-upgrade review edge cases
* fix: address self-upgrade review nits
* fix: address follow-up self-upgrade review
* fix: resolve self-upgrade review and Windows CI failures
- README: promote "Optional Commands" to ### so it is a sibling of
"Core Commands" under "Available Slash Commands" (consistent heading
levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
so the assertion holds on Windows; skip the relative-installer-path
executable-bit tests on Windows via a new requires_posix marker (they
rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
hold there). Add a combined prerelease+build-metadata tag test.
* fix: address second self-upgrade review round
- self_check: clarify that the "up to date" branch is reached only for
parseable latest tags (the unparseable case returns earlier), so the
InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
instead of re-parsing the canonical strings through _is_newer; the
empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
stripped from the installer subprocess env — a deliberate fail-safe that
also catches credential-adjacent names without a recognized suffix.
* fix: address third self-upgrade review round
- self_upgrade: unify the no-op short-circuits on packaging Version
equality instead of canonical-string equality. Version("1.0") equals
Version("1.0.0") but their str() forms differ, so the old check could
misreport an equal install as "already on latest release or newer".
Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
the target so a non-version verifier result is a mismatch (exit 2)
rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
the rate-limited category so users get the same actionable token hint
as 403.
- _is_github_credential_env_key: document the precise (intentionally
broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
parametrized HTTP-status categorization test (429 -> rate limited;
404/502 -> verbatim).
* fix: address fourth self-upgrade review round
- self_upgrade: label a pinned target older than the installed version as
"Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
`--tag` option as `str | None`, consistent with the rest of the module
(verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
recognized credential suffixes and document that only these shapes are
scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
OSError path; skip the InvalidMetadataError test on Pythons where the
real exception is absent instead of fabricating it; update the pinned
downgrade test to expect the "Downgrading" label.
* fix: accept uppercase V prefix in --tag
Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
This commit is contained in:
542
tests/test_self_upgrade_execution.py
Normal file
542
tests/test_self_upgrade_execution.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user