fix: move URL install confirmation prompt before spinner (#2783) (#2784)

* fix: move URL install confirmation prompt before spinner (#2783)

The typer.confirm() prompt inside console.status() was overwritten by
Rich's spinner animation, making extension add --from <url> appear hung.

Move URL validation and the default-deny confirmation prompt before the
spinner block so the user can see and respond to the [y/N] prompt.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: guard prompt with not dev, escape from_url in Rich markup

Address PR review feedback:
- Gate URL confirmation prompt on 'not dev' so --dev + --from does not
  show a confusing prompt for a URL path that will be ignored.
- Escape from_url with rich.markup.escape() in both the warning panel
  and the download message to prevent markup injection via crafted URLs.

* fix: remove unused import, reuse safe_url, add regression tests

Address second round of PR review:
- Remove unused urllib.request import from URL install path
- Remove redundant re-import of rich.markup.escape; reuse safe_url
  computed before the spinner for download and error messages
- Add test_add_from_url_prompts_before_spinner: asserts typer.confirm
  fires before console.status spinner to prevent #2783 regression
- Add test_add_from_url_cancel_exits_cleanly: asserts declining the
  prompt exits with code 0 and prints Cancelled

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Manfred Riem
2026-06-01 07:50:03 -05:00
committed by GitHub
parent 3617cd9c02
commit 089feca75f
2 changed files with 100 additions and 30 deletions

View File

@@ -3807,6 +3807,67 @@ class TestExtensionAddCLI:
assert "bundled with spec-kit" in result.output
assert "reinstall" in result.output.lower()
def test_add_from_url_prompts_before_spinner(self, tmp_path):
"""Confirm prompt for --from <url> must fire before the console.status spinner.
Regression test for #2783: typer.confirm() inside console.status()
was overwritten by the Rich spinner, making the command appear hung.
"""
from typer.testing import CliRunner
from unittest.mock import patch, MagicMock
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
call_order: list[str] = []
original_status = MagicMock()
def record_status(*args, **kwargs):
call_order.append("spinner")
return original_status
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("specify_cli.console.status", side_effect=record_status), \
patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert "confirm" in call_order, "confirm prompt was never called"
# The confirm must fire BEFORE the spinner is entered
if "spinner" in call_order:
assert call_order.index("confirm") < call_order.index("spinner"), \
f"confirm must precede spinner, got: {call_order}"
assert result.exit_code == 0 # user declined → clean exit
def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
"""Declining the --from <url> confirmation should exit with code 0."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
project_dir = tmp_path / "test-project"
project_dir.mkdir()
(project_dir / ".specify").mkdir()
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir), \
patch("typer.confirm", return_value=False):
result = runner.invoke(
app,
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
catch_exceptions=True,
)
assert result.exit_code == 0
assert "Cancelled" in result.output
class TestDownloadExtensionBundled:
"""Tests for download_extension handling of bundled extensions."""