"""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)