Files
github-spec-kit/tests/test_self_upgrade_guidance.py
김준호 ac2cb5daf5 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.
2026-06-03 12:04:54 -05: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)