|
1 | 1 | """Installer execution, verification, and error-path tests for `specify self upgrade`.""" |
2 | 2 |
|
3 | 3 | import errno |
| 4 | +import os |
4 | 5 | import subprocess |
5 | 6 | import sys |
6 | 7 | from unittest.mock import patch |
7 | 8 |
|
8 | 9 | from specify_cli import app |
| 10 | +from specify_cli._version import _render_argv, _runs_from_uv_tool_env_on_windows |
9 | 11 |
|
10 | 12 | from tests.specify_cli.self_upgrade_helpers import ( |
11 | 13 | route_opener_open_through_urlopen, # noqa: F401 (autouse fixture) |
@@ -542,3 +544,242 @@ def test_prerelease_failure_degrades_rollback_hint_to_releases_page( |
542 | 544 | assert "Previous version was not an exact stable release tag" in out |
543 | 545 | assert "https://github.com/github/spec-kit/releases" in out |
544 | 546 | assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out |
| 547 | + |
| 548 | + |
| 549 | +class TestWindowsUvToolEnvironment: |
| 550 | + """Windows uv-tool installs cannot reinstall the environment they run from.""" |
| 551 | + |
| 552 | + def test_windows_uv_tool_prints_command_without_running_installer( |
| 553 | + self, uv_tool_argv0, clean_environ |
| 554 | + ): |
| 555 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 556 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 557 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 558 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 559 | + ), patch( |
| 560 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 561 | + ): |
| 562 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 563 | + result = runner.invoke(app, ["self", "upgrade"]) |
| 564 | + |
| 565 | + assert result.exit_code == 0 |
| 566 | + out = strip_ansi(result.output) |
| 567 | + assert "Not upgrading specify-cli 1.2.2 → v1.2.4" in out |
| 568 | + assert ( |
| 569 | + " uv tool install specify-cli --force --from " |
| 570 | + "git+https://github.com/github/spec-kit.git@v1.2.4" |
| 571 | + ) in out |
| 572 | + assert "Upgrade failed" not in out |
| 573 | + assert mock_run.call_count == 0 |
| 574 | + |
| 575 | + def test_windows_uv_tool_already_on_latest_is_noop( |
| 576 | + self, uv_tool_argv0, clean_environ |
| 577 | + ): |
| 578 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 579 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 580 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 581 | + "specify_cli._version._get_installed_version", return_value="1.2.4" |
| 582 | + ), patch( |
| 583 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 584 | + ): |
| 585 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 586 | + result = runner.invoke(app, ["self", "upgrade"]) |
| 587 | + |
| 588 | + assert result.exit_code == 0 |
| 589 | + out = strip_ansi(result.output) |
| 590 | + assert "Already on latest release: v1.2.4" in out |
| 591 | + assert "Not upgrading" not in out |
| 592 | + assert "uv tool install" not in out |
| 593 | + assert mock_run.call_count == 0 |
| 594 | + |
| 595 | + def test_windows_uv_tool_already_on_requested_tag_is_noop( |
| 596 | + self, uv_tool_argv0, clean_environ |
| 597 | + ): |
| 598 | + with patch("specify_cli._version.shutil.which", return_value=sys.executable), patch( |
| 599 | + "specify_cli._version.subprocess.run" |
| 600 | + ) as mock_run, patch( |
| 601 | + "specify_cli._version._get_installed_version", return_value="1.2.4" |
| 602 | + ), patch( |
| 603 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 604 | + ): |
| 605 | + result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.2.4"]) |
| 606 | + |
| 607 | + assert result.exit_code == 0 |
| 608 | + out = strip_ansi(result.output) |
| 609 | + assert "Already on requested release: v1.2.4" in out |
| 610 | + assert "Not upgrading" not in out |
| 611 | + assert "uv tool install" not in out |
| 612 | + assert mock_run.call_count == 0 |
| 613 | + |
| 614 | + def test_windows_pipx_still_runs_installer(self, pipx_argv0, clean_environ): |
| 615 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 616 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 617 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 618 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 619 | + ), patch( |
| 620 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 621 | + ): |
| 622 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 623 | + mock_run.side_effect = [ |
| 624 | + _completed_process(0), # installer |
| 625 | + _completed_process(0, stdout="specify 1.2.4\n"), # verify |
| 626 | + ] |
| 627 | + result = runner.invoke(app, ["self", "upgrade"]) |
| 628 | + |
| 629 | + assert result.exit_code == 0 |
| 630 | + assert "Upgraded specify-cli: 1.2.2 → 1.2.4" in strip_ansi(result.output) |
| 631 | + assert mock_run.call_count == 2 |
| 632 | + |
| 633 | + def test_windows_uv_tool_dry_run_previews_manual_command( |
| 634 | + self, uv_tool_argv0, clean_environ |
| 635 | + ): |
| 636 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 637 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 638 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 639 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 640 | + ), patch( |
| 641 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 642 | + ): |
| 643 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 644 | + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) |
| 645 | + |
| 646 | + assert result.exit_code == 0 |
| 647 | + out = strip_ansi(result.output) |
| 648 | + assert "Dry run — no changes will be made." in out |
| 649 | + assert "Target version: v1.2.4" in out |
| 650 | + assert ( |
| 651 | + "Command that would be executed: (none — on Windows, uv cannot " |
| 652 | + "replace the tool environment specify is running from)" |
| 653 | + ) in " ".join(out.split()) |
| 654 | + assert ( |
| 655 | + "Stop any other specify process (such as `specify mcp`), then run " |
| 656 | + "this once this command exits:\n" |
| 657 | + " uv tool install specify-cli --force --from " |
| 658 | + "git+https://github.com/github/spec-kit.git@v1.2.4\n" |
| 659 | + ) in out |
| 660 | + assert mock_run.call_count == 0 |
| 661 | + |
| 662 | + def test_windows_uv_tool_dry_run_with_tag_previews_pinned_command( |
| 663 | + self, uv_tool_argv0, clean_environ |
| 664 | + ): |
| 665 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 666 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 667 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 668 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 669 | + ), patch( |
| 670 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 671 | + ): |
| 672 | + result = runner.invoke( |
| 673 | + app, ["self", "upgrade", "--dry-run", "--tag", "v1.2.0"] |
| 674 | + ) |
| 675 | + |
| 676 | + assert result.exit_code == 0 |
| 677 | + assert ( |
| 678 | + "\n uv tool install specify-cli --force --from " |
| 679 | + "git+https://github.com/github/spec-kit.git@v1.2.0\n" |
| 680 | + ) in strip_ansi(result.output) |
| 681 | + mock_urlopen.assert_not_called() |
| 682 | + assert mock_run.call_count == 0 |
| 683 | + |
| 684 | + def test_windows_uv_tool_dry_run_matches_real_run_command( |
| 685 | + self, uv_tool_argv0, clean_environ |
| 686 | + ): |
| 687 | + outputs = {} |
| 688 | + for args in (["self", "upgrade", "--dry-run"], ["self", "upgrade"]): |
| 689 | + with patch( |
| 690 | + "specify_cli.authentication.http.urllib.request.urlopen" |
| 691 | + ) as mock_urlopen, patch( |
| 692 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 693 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 694 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 695 | + ), patch( |
| 696 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", |
| 697 | + return_value=True, |
| 698 | + ): |
| 699 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 700 | + result = runner.invoke(app, args) |
| 701 | + assert result.exit_code == 0 |
| 702 | + assert mock_run.call_count == 0 |
| 703 | + outputs[len(args)] = strip_ansi(result.output) |
| 704 | + |
| 705 | + manual_steps = ( |
| 706 | + "Stop any other specify process (such as `specify mcp`), then run " |
| 707 | + "this once this command exits:\n" |
| 708 | + " uv tool install specify-cli --force --from " |
| 709 | + "git+https://github.com/github/spec-kit.git@v1.2.4\n" |
| 710 | + ) |
| 711 | + assert manual_steps in outputs[3] |
| 712 | + assert manual_steps in outputs[2] |
| 713 | + |
| 714 | + def test_non_windows_uv_tool_dry_run_still_previews_installer( |
| 715 | + self, uv_tool_argv0, clean_environ |
| 716 | + ): |
| 717 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 718 | + "specify_cli._version.shutil.which", return_value=sys.executable |
| 719 | + ), patch("specify_cli._version.subprocess.run"), patch( |
| 720 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 721 | + ), patch( |
| 722 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=False |
| 723 | + ): |
| 724 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 725 | + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) |
| 726 | + |
| 727 | + assert result.exit_code == 0 |
| 728 | + out = strip_ansi(result.output) |
| 729 | + expected_argv = _render_argv( |
| 730 | + [ |
| 731 | + sys.executable, |
| 732 | + "tool", |
| 733 | + "install", |
| 734 | + "specify-cli", |
| 735 | + "--force", |
| 736 | + "--from", |
| 737 | + "git+https://github.com/github/spec-kit.git@v1.2.4", |
| 738 | + ] |
| 739 | + ) |
| 740 | + assert f"Command that would be executed: {expected_argv}" in " ".join( |
| 741 | + out.split() |
| 742 | + ) |
| 743 | + assert "then run this once this command exits" not in out |
| 744 | + |
| 745 | + def test_windows_uv_tool_dry_run_without_uv_reports_missing_installer( |
| 746 | + self, uv_tool_argv0, clean_environ |
| 747 | + ): |
| 748 | + with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch( |
| 749 | + "specify_cli._version.shutil.which", return_value=None |
| 750 | + ), patch("specify_cli._version.subprocess.run") as mock_run, patch( |
| 751 | + "specify_cli._version._get_installed_version", return_value="1.2.2" |
| 752 | + ), patch( |
| 753 | + "specify_cli._version._runs_from_uv_tool_env_on_windows", return_value=True |
| 754 | + ): |
| 755 | + mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.2.4"}) |
| 756 | + result = runner.invoke(app, ["self", "upgrade", "--dry-run"]) |
| 757 | + |
| 758 | + assert result.exit_code == 0 |
| 759 | + out = strip_ansi(result.output) |
| 760 | + assert "Command that would be executed: (installer uv not found on PATH)" in out |
| 761 | + assert "then run this once this command exits" not in out |
| 762 | + assert mock_run.call_count == 0 |
| 763 | + |
| 764 | + def test_help_mentions_windows_uv_tool_exception(self): |
| 765 | + result = runner.invoke(app, ["self", "upgrade", "--help"]) |
| 766 | + assert result.exit_code == 0 |
| 767 | + out = " ".join(strip_ansi(result.output).split()) |
| 768 | + assert "On Windows, uv cannot replace the uv-tool environment" in out |
| 769 | + |
| 770 | + def test_detects_uv_tool_environment_on_windows(self, tmp_path, monkeypatch): |
| 771 | + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") |
| 772 | + monkeypatch.setattr("sys.prefix", str(tmp_path)) |
| 773 | + monkeypatch.setattr(os, "name", "nt") |
| 774 | + assert _runs_from_uv_tool_env_on_windows() is True |
| 775 | + |
| 776 | + def test_ignores_environment_without_uv_receipt(self, tmp_path, monkeypatch): |
| 777 | + monkeypatch.setattr("sys.prefix", str(tmp_path)) |
| 778 | + monkeypatch.setattr(os, "name", "nt") |
| 779 | + assert _runs_from_uv_tool_env_on_windows() is False |
| 780 | + |
| 781 | + def test_ignores_uv_tool_environment_off_windows(self, tmp_path, monkeypatch): |
| 782 | + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") |
| 783 | + monkeypatch.setattr("sys.prefix", str(tmp_path)) |
| 784 | + monkeypatch.setattr(os, "name", "posix") |
| 785 | + assert _runs_from_uv_tool_env_on_windows() is False |
0 commit comments