Files
github--spec-kit/tests/test_self_upgrade_guidance.py
2026-07-13 11:57:40 +08:00

185 lines
7.1 KiB
Python

"""Non-upgradable path guidance tests for `specify self upgrade`."""
from unittest.mock import patch
from specify_cli import app
from tests.self_upgrade_helpers import (
mock_urlopen_response,
runner,
strip_ansi,
)
# ===========================================================================
# Phase 5 — User Story 3: non-upgradable path guidance (P3)
# ===========================================================================
class TestUvxEphemeral:
"""uvx ephemeral path emits exact one-liner, no installer call."""
def test_uvx_argv0_prints_exact_one_liner_and_exits_zero(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
expected = (
"Running via uvx (ephemeral); the next uvx invocation already "
"resolves to latest — no upgrade action needed."
)
assert expected in strip_ansi(result.output)
assert mock_run.call_count == 0
def test_offline_still_exits_zero_without_tag_resolution(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=AssertionError("non-upgradable uvx path must not hit network"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
assert "uvx (ephemeral)" in strip_ansi(result.output)
class TestSourceCheckout:
"""Editable install path emits git pull guidance."""
def test_source_checkout_prints_git_pull_guidance(
self,
unsupported_argv0,
tmp_path,
clean_environ,
):
fake_tree = tmp_path / "worktree"
fake_tree.mkdir()
(fake_tree / ".git").mkdir()
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
"specify_cli._version._source_checkout_path", return_value=fake_tree
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert f"Running from a source checkout at {fake_tree}" in out
assert "git pull" in out
assert "pip install -e ." in out
assert mock_run.call_count == 0
def test_source_checkout_without_path_mentions_checkout_directory(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
"specify_cli._version._source_checkout_path", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
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 == 0
assert "checkout path could not be detected" in out
assert "from your checkout directory" in out
assert "(path unavailable)" not in out
assert mock_run.call_count == 0
class TestUnsupported:
"""Unsupported path enumerates manual reinstall commands."""
def test_unsupported_prints_both_reinstall_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
"specify_cli._version.subprocess.run"
) as mock_run:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Could not identify your install method automatically" in out
assert (
"uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
) in out
assert (
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
in out
)
assert mock_run.call_count == 0
def test_unsupported_offline_degrades_to_placeholder_manual_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch(
"specify_cli.authentication.http.urllib.request.urlopen",
side_effect=AssertionError("unsupported guidance should not require network"),
):
result = runner.invoke(app, ["self", "upgrade"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Could not identify your install method automatically" in out
assert (
"uv tool install specify-cli --force --from "
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
) in out
assert (
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
in out
)
class TestDryRunNonUpgradablePaths:
"""--dry-run on non-upgradable paths emits guidance, not preview."""
def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview(
self,
uvx_ephemeral_argv0,
clean_environ,
):
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
out = strip_ansi(result.output)
assert "Dry run — no changes will be made." not in out
assert "uvx (ephemeral)" in out
def test_dry_run_on_unsupported_emits_manual_commands(
self,
unsupported_argv0,
clean_environ,
):
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
"specify_cli._version.shutil.which", return_value=None
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
assert result.exit_code == 0
assert "Could not identify your install method" in strip_ansi(result.output)