mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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.
This commit is contained in:
22
README.md
22
README.md
@@ -59,6 +59,24 @@ specify init my-project --integration copilot
|
||||
cd my-project
|
||||
```
|
||||
|
||||
To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options.
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with your desired release tag)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. For `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work, including dev, alpha/beta/rc, or build metadata suffixes. `uvx` (ephemeral) runs and source checkouts are detected and produce path-specific guidance instead of running an installer. Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed).
|
||||
|
||||
### 3. Establish project principles
|
||||
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead.
|
||||
@@ -133,7 +151,7 @@ Run `specify integration list` to see all available integrations in your install
|
||||
|
||||
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. For integrations that support skills mode, passing `--integration <agent> --integration-options="--skills"` installs agent skills instead of slash-command prompt files.
|
||||
|
||||
#### Core Commands
|
||||
### Core Commands
|
||||
|
||||
Essential commands for the Spec-Driven Development workflow:
|
||||
|
||||
@@ -146,7 +164,7 @@ Essential commands for the Spec-Driven Development workflow:
|
||||
| `/speckit.taskstoissues` | `speckit-taskstoissues`| Convert generated task lists into GitHub issues for tracking and execution |
|
||||
| `/speckit.implement` | `speckit-implement` | Execute all tasks to build the feature according to the plan |
|
||||
|
||||
#### Optional Commands
|
||||
### Optional Commands
|
||||
|
||||
Additional commands for enhanced quality and validation:
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ specify version
|
||||
|
||||
This helps verify you are running the official Spec Kit build from GitHub, not an unrelated package with the same name.
|
||||
|
||||
**Stay current:** Run `specify self check` periodically to learn whether a newer release is available — it is read-only and never modifies your installation. When you are ready to upgrade, follow the [Upgrade Guide](./upgrade.md).
|
||||
|
||||
After initialization, you should see the following commands available in your coding agent:
|
||||
|
||||
- `/speckit.specify` - Create specifications
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
|
||||
| What to Upgrade | Command | When to Use |
|
||||
|----------------|---------|-------------|
|
||||
| **CLI Tool Only** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | Get latest CLI features without touching project files |
|
||||
| **CLI Tool Only (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Reinstall/upgrade a pipx-installed CLI to a specific release |
|
||||
| **CLI Tool (recommended)** | `specify self upgrade` | Latest stable release, in place. Auto-detects whether you installed via `uv tool` or `pipx`. |
|
||||
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
|
||||
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
|
||||
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
|
||||
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
@@ -19,12 +21,32 @@
|
||||
|
||||
The CLI tool (`specify`) is separate from your project files. Upgrade it to get the latest features and bug fixes.
|
||||
|
||||
Before upgrading, you can check whether a newer released version is available:
|
||||
### Recommended: `specify self upgrade`
|
||||
|
||||
The CLI ships with two self-management commands that handle the common case automatically:
|
||||
|
||||
```bash
|
||||
# Check whether a newer release is available (read-only — does not modify anything)
|
||||
specify self check
|
||||
|
||||
# Preview what would run, without actually upgrading
|
||||
specify self upgrade --dry-run
|
||||
|
||||
# Upgrade in place to the latest stable release (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Or pin a specific release tag (replace vX.Y.Z[suffix] with the tag you want)
|
||||
specify self upgrade --tag vX.Y.Z[suffix]
|
||||
```
|
||||
|
||||
Bare `specify self upgrade` executes immediately, matching the no-prompt behavior of commands like `pip install -U` and `npm update`. The CLI classifies your runtime into one of: `uv tool`, `pipx`, `uvx (ephemeral)`, source checkout, or unsupported. Only `uv tool` and `pipx` are upgraded automatically; for `uv tool` installs, it runs `uv tool install specify-cli --force --from <git ref>` under the hood so pinned release tags work. The other paths print path-specific guidance and exit 0 without touching anything.
|
||||
|
||||
Pinned tags must start with `vMAJOR.MINOR.PATCH`. Optional suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms such as `v1.0.0-rc1`, `v0.8.0.dev0`, `v0.8.0+build.42`, or the combination `v1.0.0-rc1+build.42`; branch names, hash refs, `latest`, and bare versions without `v` are rejected.
|
||||
|
||||
Set `SPECIFY_UPGRADE_TIMEOUT_SECS` to cap how long the installer subprocess may run (default: no timeout — interrupt with `Ctrl+C` if needed). If that internal timeout fires, `specify self upgrade` exits 124 and reports that it timed out while waiting for the installer subprocess, including the configured timeout and manual retry command. A real installer exit code 124 is propagated with `Upgrade failed. Installer exit code: 124.`, so scripts should treat exit 124 as ambiguous and inspect the message when they need to distinguish the two cases.
|
||||
|
||||
If your installed CLI is older than the release that introduced `specify self upgrade`, use the manual equivalents below. These commands are also useful when you want explicit control over the installer command.
|
||||
|
||||
### If you installed with `uv tool install`
|
||||
|
||||
Upgrade to a specific release (check [Releases](https://github.com/github/spec-kit/releases) for the latest tag):
|
||||
@@ -54,10 +76,14 @@ pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z
|
||||
### Verify the upgrade
|
||||
|
||||
```bash
|
||||
# Confirms the CLI is working and shows installed tools
|
||||
specify check
|
||||
|
||||
# Confirms the installed version against the latest GitHub release
|
||||
specify self check
|
||||
```
|
||||
|
||||
This shows installed tools and confirms the CLI is working. Use `specify version` to confirm which persistent CLI version is currently on your `PATH`.
|
||||
`specify check` shows the surrounding tool environment; `specify self check` is read-only and tells you whether you're now on the latest release (`Up to date: X.Y.Z`) or if a newer one became available between releases.
|
||||
|
||||
---
|
||||
|
||||
@@ -186,8 +212,8 @@ Restart your IDE to refresh the command list.
|
||||
### Scenario 1: "I just want new slash commands"
|
||||
|
||||
```bash
|
||||
# Upgrade CLI (if using persistent install)
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
# Upgrade CLI (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Update project files to get new commands
|
||||
specify init --here --force --integration copilot
|
||||
@@ -204,7 +230,7 @@ cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp -r .specify/templates /tmp/templates-backup
|
||||
|
||||
# 2. Upgrade CLI
|
||||
uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git
|
||||
specify self upgrade
|
||||
|
||||
# 3. Update project
|
||||
specify init --here --force --integration copilot
|
||||
@@ -388,15 +414,19 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
If a command behaves like an older Spec Kit version, first check for local CLI drift:
|
||||
If a command behaves like an older Spec Kit version, first ask the CLI itself:
|
||||
|
||||
```bash
|
||||
# Read-only — prints "Up to date: X.Y.Z" or "Update available: X.Y.Z → vY.Z.W"
|
||||
specify self check
|
||||
|
||||
# Preview the install method, current version, and target tag the upgrade would use
|
||||
specify self upgrade --dry-run
|
||||
```
|
||||
|
||||
`specify check` is an offline environment scan; `specify self check` is the CLI version lookup.
|
||||
|
||||
Verify the installation:
|
||||
If `self check` shows the wrong version, verify the installation:
|
||||
|
||||
```bash
|
||||
# Check installed tools
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -81,3 +81,72 @@ def _isolate_auth_config(monkeypatch):
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environ(monkeypatch):
|
||||
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
|
||||
|
||||
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
||||
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
||||
monkeypatch.setenv(env_name, str(tmp_path))
|
||||
fake_dir = tmp_path.joinpath(*path_parts)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
return fake_specify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uv_tool_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"HOME",
|
||||
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipx_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"LOCALAPPDATA",
|
||||
("uv", "cache", "archive-v0", "abc123", "bin"),
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsupported_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
||||
)
|
||||
|
||||
15
tests/http_helpers.py
Normal file
15
tests/http_helpers.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""HTTP test helpers shared by version-related CLI tests."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
"""Build a urlopen context-manager mock whose read returns JSON."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
64
tests/self_upgrade_helpers.py
Normal file
64
tests/self_upgrade_helpers.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Shared fixtures and helpers for `specify self upgrade` tests.
|
||||
|
||||
These helpers patch subprocess, PATH lookup, and release-tag resolution so
|
||||
the focused test modules stay isolated from the real environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli._version import (
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_assemble_installer_argv,
|
||||
_detect_install_method,
|
||||
_verify_upgrade,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
__all__ = (
|
||||
"SENTINEL_GH_TOKEN",
|
||||
"SENTINEL_GITHUB_TOKEN",
|
||||
"_InstallMethod",
|
||||
"_UpgradePlan",
|
||||
"_assemble_installer_argv",
|
||||
"_completed_process",
|
||||
"_detect_install_method",
|
||||
"_verify_upgrade",
|
||||
"mock_urlopen_response",
|
||||
"requires_posix",
|
||||
"runner",
|
||||
"strip_ansi",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
|
||||
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
|
||||
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
|
||||
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
|
||||
# fixtures raise PermissionError during teardown. Skip these on Windows — the
|
||||
# realistic absolute-path and bare-PATH-command branches stay covered there.
|
||||
requires_posix = pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="relative-path / executable-bit semantics are POSIX-only",
|
||||
)
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _completed_process(
|
||||
returncode: int, stdout: str = "", stderr: str = ""
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Build a subprocess.CompletedProcess for installer / verification calls."""
|
||||
return subprocess.CompletedProcess(
|
||||
args=["mocked"],
|
||||
returncode=returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
887
tests/test_self_upgrade_detection.py
Normal file
887
tests/test_self_upgrade_detection.py
Normal file
@@ -0,0 +1,887 @@
|
||||
"""Detection, argv assembly, and dry-run tests for `specify self upgrade`."""
|
||||
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_InstallMethod,
|
||||
_assemble_installer_argv,
|
||||
_completed_process,
|
||||
_detect_install_method,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionUvTool:
|
||||
"""Tier-1 path-prefix detection for uv-tool installs."""
|
||||
|
||||
def test_posix_uv_tool_prefix_matches(self, uv_tool_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 1
|
||||
assert "uv/tools/specify-cli" in signals.matched_prefix.replace("\\", "/")
|
||||
|
||||
def test_detection_is_deterministic(self, uv_tool_argv0):
|
||||
a = _detect_install_method()
|
||||
b = _detect_install_method()
|
||||
assert a == b == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_no_argv_match_falls_through_to_unsupported(self, unsupported_argv0):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_include_signals_false_returns_bare_enum(self, uv_tool_argv0):
|
||||
result = _detect_install_method(include_signals=False)
|
||||
assert isinstance(result, _InstallMethod)
|
||||
|
||||
def test_bare_argv0_is_resolved_via_path_lookup(self, monkeypatch, tmp_path):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_specify) if name == "specify" else None,
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_prefix_match_does_not_accept_sibling_directory(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli2" / "bin"
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version._editable_marker_seen", return_value=False
|
||||
):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_when_registry_lists_exact_name(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\nother-tool v1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_unresolved_bare_argv0_skips_tier3_registry_detection(self, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
def test_bare_argv0_missing_path_resolution_allows_tier3_registry_detection(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
missing_specify = tmp_path / "missing" / "specify"
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(missing_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
assert signals.matched_tier == 3
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
|
||||
def test_missing_relative_argv0_falls_back_to_entrypoint_name_lookup(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
if os.name == "nt":
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
|
||||
fake_dir = tmp_path / "uv" / "tools" / "specify-cli" / "bin"
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
fake_dir = (
|
||||
tmp_path / ".local" / "share" / "uv" / "tools" / "specify-cli" / "bin"
|
||||
)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
monkeypatch.setattr("sys.argv", ["./bin/specify"])
|
||||
|
||||
def fake_which(name):
|
||||
return str(fake_specify) if name == "specify" else None
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which):
|
||||
method = _detect_install_method()
|
||||
|
||||
assert method == _InstallMethod.UV_TOOL
|
||||
|
||||
def test_tier3_uv_tool_ignores_substring_false_positive(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="my-specify-cli-helper v0.1.0\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "uv" if name == "uv" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_uv_tool_does_not_override_resolved_bare_unsupported_entrypoint(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
venv_bin = tmp_path / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
fake_specify = venv_bin / "specify"
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", ["specify"])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "specify":
|
||||
return str(fake_specify)
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert signals.installer_registries_consulted == ()
|
||||
|
||||
|
||||
class TestPrefixExpansion:
|
||||
"""Path-prefix expansion edge cases."""
|
||||
|
||||
def test_literal_dollar_without_variable_name_is_preserved(self, tmp_path):
|
||||
prefix_path = tmp_path / "specify-$-cache" / "tools" / "specify-cli"
|
||||
prefix = str(prefix_path)
|
||||
|
||||
expanded = specify_cli._version._expand_prefix(prefix)
|
||||
|
||||
assert expanded == prefix_path.resolve()
|
||||
|
||||
def test_unresolved_posix_variable_is_rejected(self):
|
||||
assert specify_cli._version._expand_prefix("$SPECIFY_MISSING/specify-cli/") is None
|
||||
|
||||
def test_absolute_prefix_resolve_oserror_is_rejected(self, tmp_path):
|
||||
prefix = str(tmp_path / "specify-cli")
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._expand_prefix(prefix) is None
|
||||
|
||||
|
||||
class TestArgv0Resolution:
|
||||
"""Entrypoint path resolution edge cases."""
|
||||
|
||||
def test_absolute_argv0_resolve_oserror_returns_original_path(self, tmp_path):
|
||||
argv0 = tmp_path / "specify"
|
||||
|
||||
with patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
assert specify_cli._version._resolved_argv0_path(str(argv0)) == argv0
|
||||
|
||||
def test_path_lookup_resolve_oserror_returns_unresolved_lookup_path(self):
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", return_value="/broken/specify"
|
||||
), patch("pathlib.Path.resolve", side_effect=OSError("bad path")):
|
||||
result = specify_cli._version._resolved_argv0_path("specify")
|
||||
|
||||
# Compare as Path objects: on Windows the same logical path renders
|
||||
# with backslashes, so a raw string compare against the POSIX form
|
||||
# would spuriously fail.
|
||||
assert result == Path("/broken/specify")
|
||||
|
||||
|
||||
class TestArgvAssemblyUvTool:
|
||||
"""uv-tool installer argv shape."""
|
||||
|
||||
def test_stable_tag_produces_expected_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6")
|
||||
assert argv == [
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
|
||||
def test_dev_suffix_tag_embedded_literally(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.8.0.dev0")
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.8.0.dev0" in argv
|
||||
assert (
|
||||
"upgrade" not in argv
|
||||
) # never `uv tool upgrade` — does not accept --tag pinning
|
||||
|
||||
def test_missing_uv_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.UV_TOOL, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradeUvTool:
|
||||
"""uv-tool happy path, bare invocation."""
|
||||
|
||||
def test_happy_path_end_to_end(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer
|
||||
_completed_process(0, stdout="specify 0.7.6\n"), # verify
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli 0.7.5 → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
assert mock_run.call_count == 2
|
||||
for call in mock_run.call_args_list:
|
||||
assert call.kwargs.get("shell", False) is False
|
||||
|
||||
def test_one_user_action_no_prompt(self, uv_tool_argv0, clean_environ):
|
||||
# The single `invoke` represents the single user action — no prompt.
|
||||
# If a prompt existed, runner.invoke would hang waiting for input.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestAlreadyLatestUvTool:
|
||||
"""already on latest, no installer launched."""
|
||||
|
||||
def test_already_latest_exits_zero_no_subprocess(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.6"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release: v0.7.6" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_trailing_zero_equivalent_version_reports_latest_not_newer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Version("1.0") == Version("1.0.0") under packaging even though their
|
||||
# canonical strings differ. The no-op message must use Version equality
|
||||
# so this prints "Already on latest release", not "... or newer".
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="1.0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release: v1.0.0" in out
|
||||
assert "or newer" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dev_build_ahead_of_release_reports_newer_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.7.dev0"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on latest release or newer: 0.7.7.dev0" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_unparseable_current_version_does_not_false_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
assert "Upgrading specify-cli release-main → v0.7.6 via uv tool:" in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_unparseable_resolved_target_fails_before_literal_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="release-main"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Already on latest release" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_pinned_older_tag_still_runs_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.6"
|
||||
):
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.5\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v0.7.5"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Already on latest release" not in out
|
||||
# A pinned older tag is a downgrade and must be labelled as such.
|
||||
assert "Downgrading specify-cli 0.7.6 → v0.7.5 via uv tool:" in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_pinned_rc_tag_uses_canonical_version_equality_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-rc1" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestDryRunUvTool:
|
||||
"""--dry-run preview path + --dry-run combined with --tag."""
|
||||
|
||||
def test_dry_run_without_tag_resolves_network_but_no_subprocess(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
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." in out
|
||||
assert "Detected install method: uv tool" in out
|
||||
assert "Current version: 0.7.5" in out
|
||||
assert "Target version: v0.7.6" in out
|
||||
assert "Command that would be executed:" in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_tag_skips_network(self, uv_tool_argv0, clean_environ):
|
||||
# --dry-run with --tag must NOT hit the network.
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
), patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0" in strip_ansi(result.output)
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
def test_dry_run_rejects_unparseable_network_tag_before_preview(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response(
|
||||
{"tag_name": "v0.9.0;echo unsafe"}
|
||||
)
|
||||
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
||||
|
||||
out = strip_ansi(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "not a comparable version" in out
|
||||
assert "v0.9.0;echo unsafe" not in out
|
||||
assert "Command that would be executed:" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
def test_dry_run_with_missing_uv_flags_unresolved_installer(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value=None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
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 "Command that would be executed: (installer uv not found on PATH)" in out
|
||||
assert "uv tool install" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 4 — User Story 2: `pipx` immediate upgrade (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestDetectionPipx:
|
||||
"""Pipx detection — tier 1 (path) and tier 3 (registry)."""
|
||||
|
||||
def test_posix_pipx_prefix_matches(self, pipx_argv0):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 1
|
||||
|
||||
def test_tier3_pipx_when_no_prefix_match_but_registry_lists_it(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.PIPX
|
||||
assert signals.matched_tier == 3
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
def test_tier3_pipx_does_not_override_absolute_unsupported_entrypoint(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_pipx_ignores_malformed_json_output(
|
||||
self,
|
||||
unsupported_argv0,
|
||||
):
|
||||
def fake_which(name):
|
||||
return "pipx" if name == "pipx" else None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="not json but mentions specify-cli",
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
|
||||
def test_tier3_both_uv_tool_and_pipx_match_is_treated_as_unsupported(
|
||||
self,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
monkeypatch.setattr("sys.argv", [str(tmp_path / "missing" / "specify")])
|
||||
|
||||
def fake_which(name):
|
||||
if name == "uv":
|
||||
return "uv"
|
||||
if name == "pipx":
|
||||
return "pipx"
|
||||
return None
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
if argv[:3] == ["uv", "tool", "list"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout="specify-cli v0.7.6\n",
|
||||
stderr="",
|
||||
)
|
||||
if argv[:3] == ["pipx", "list", "--json"]:
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=0,
|
||||
stdout='{"venvs":{"specify-cli":{}}}',
|
||||
stderr="",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv, returncode=1, stdout="", stderr=""
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", side_effect=fake_which), patch(
|
||||
"specify_cli._version.subprocess.run", side_effect=fake_run
|
||||
), patch("specify_cli._version._editable_marker_seen", return_value=False):
|
||||
method, signals = _detect_install_method(include_signals=True)
|
||||
assert method == _InstallMethod.UNSUPPORTED
|
||||
assert signals.matched_tier is None
|
||||
assert "uv tool list" in signals.installer_registries_consulted
|
||||
assert "pipx list --json" in signals.installer_registries_consulted
|
||||
|
||||
|
||||
class TestEditableInstallMetadata:
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(importlib.metadata, "InvalidMetadataError"),
|
||||
reason=(
|
||||
"importlib.metadata.InvalidMetadataError does not exist on this "
|
||||
"Python; _editable_direct_url_path only catches it when present, so "
|
||||
"fabricating it would exercise a path that cannot fire in production"
|
||||
),
|
||||
)
|
||||
def test_editable_marker_false_when_metadata_is_invalid(self):
|
||||
invalid_metadata_error = importlib.metadata.InvalidMetadataError
|
||||
|
||||
with patch(
|
||||
"importlib.metadata.distribution",
|
||||
side_effect=invalid_metadata_error("bad metadata"),
|
||||
):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
assert specify_cli._version._source_checkout_path() is None
|
||||
|
||||
def test_direct_url_editable_install_marks_source_checkout(self, tmp_path):
|
||||
project_root = tmp_path / "spec-kit"
|
||||
project_root.mkdir()
|
||||
(project_root / ".git").mkdir()
|
||||
|
||||
class FakeDist:
|
||||
files = []
|
||||
|
||||
def read_text(self, name):
|
||||
if name == "direct_url.json":
|
||||
return json.dumps(
|
||||
{
|
||||
"dir_info": {"editable": True},
|
||||
"url": project_root.as_uri(),
|
||||
}
|
||||
)
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is True
|
||||
assert specify_cli._version._source_checkout_path() == project_root.resolve()
|
||||
|
||||
def test_editable_marker_false_without_explicit_editable_metadata(self, tmp_path):
|
||||
repo_root = tmp_path / "repo"
|
||||
repo_root.mkdir()
|
||||
(repo_root / ".git").mkdir()
|
||||
venv_file = repo_root / ".venv" / "lib" / "python3.13" / "site-packages" / "specify_cli.py"
|
||||
venv_file.parent.mkdir(parents=True)
|
||||
venv_file.write_text("# installed module\n")
|
||||
|
||||
class FakeDist:
|
||||
files = ["specify_cli.py"]
|
||||
|
||||
def read_text(self, name):
|
||||
return None
|
||||
|
||||
def locate_file(self, file):
|
||||
return venv_file
|
||||
|
||||
with patch("importlib.metadata.distribution", return_value=FakeDist()):
|
||||
assert specify_cli._version._editable_marker_seen() is False
|
||||
|
||||
|
||||
class TestTagValidationWhitespace:
|
||||
def test_tag_whitespace_is_trimmed_before_validation(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.8.0\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", " v0.8.0 "])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "v0.8.0" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestArgvAssemblyPipx:
|
||||
"""pipx installer argv shape — pipx 1.5+ uses positional PACKAGE_SPEC, never `--spec` or `upgrade`."""
|
||||
|
||||
def test_pipx_argv_uses_install_force_positional_not_upgrade(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value="pipx"):
|
||||
argv = _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6")
|
||||
assert argv == [
|
||||
"pipx",
|
||||
"install",
|
||||
"--force",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
]
|
||||
assert "upgrade" not in argv # pipx upgrade does not accept arbitrary refs
|
||||
assert "--spec" not in argv # pipx 1.5+ dropped the --spec flag
|
||||
|
||||
def test_missing_pipx_returns_no_installer_argv(self):
|
||||
with patch("specify_cli._version.shutil.which", return_value=None):
|
||||
assert _assemble_installer_argv(_InstallMethod.PIPX, "v0.7.6") is None
|
||||
|
||||
|
||||
class TestBareUpgradePipx:
|
||||
"""pipx happy path."""
|
||||
|
||||
def test_happy_path(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "via pipx:" in out
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in out
|
||||
|
||||
|
||||
class TestDetectionShortCircuit:
|
||||
"""Tier-1 path-prefix matches short-circuit before registry checks."""
|
||||
|
||||
def test_pipx_argv0_prefix_short_circuits_before_registry_checks(
|
||||
self,
|
||||
pipx_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="/usr/bin/X"), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run:
|
||||
method = _detect_install_method()
|
||||
assert method == _InstallMethod.PIPX
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestDryRunPipx:
|
||||
def test_dry_run_preview_names_pipx(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
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 "Detected install method: pipx" in strip_ansi(result.output)
|
||||
assert mock_run.call_count == 0
|
||||
542
tests/test_self_upgrade_execution.py
Normal file
542
tests/test_self_upgrade_execution.py
Normal file
@@ -0,0 +1,542 @@
|
||||
"""Installer execution, verification, and error-path tests for `specify self upgrade`."""
|
||||
|
||||
import errno
|
||||
import subprocess
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
_completed_process,
|
||||
mock_urlopen_response,
|
||||
requires_posix,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInstallerMissing:
|
||||
"""Installer disappeared between detection and run → exit 3."""
|
||||
|
||||
def test_uv_missing_exits_3(self, uv_tool_argv0, clean_environ):
|
||||
which_results = {"specify": "/usr/local/bin/specify"}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv not found on PATH; reinstall it and retry." in out
|
||||
assert "Upgrading specify-cli" not in out
|
||||
|
||||
def test_pipx_missing_exits_3(self, pipx_argv0, clean_environ):
|
||||
which_results = {}
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda n: which_results.get(n)
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert "Installer pipx not found on PATH" in strip_ansi(result.output)
|
||||
|
||||
def test_absolute_installer_path_does_not_require_path_lookup(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_does_not_require_path_lookup(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._verify_upgrade", return_value="0.7.6"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(0)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args.args[0][0] == "./uv"
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_missing_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
"Installer path ./uv no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
assert "not found on PATH" not in strip_ansi(result.output)
|
||||
|
||||
def test_resolved_absolute_installer_removed_before_exec_gets_missing_path_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o755)
|
||||
|
||||
def fake_run(argv, *args, **kwargs):
|
||||
fake_uv.unlink()
|
||||
raise FileNotFoundError(str(fake_uv))
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: str(fake_uv) if name == "uv" else None,
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=fake_run), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_absolute_installer_path_not_executable_gets_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} is not an executable file; fix the path or reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
@requires_posix
|
||||
def test_relative_installer_path_not_executable_gets_path_specific_message(
|
||||
self, monkeypatch, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "uv"
|
||||
fake_uv.write_text("#!/bin/sh\n")
|
||||
fake_uv.chmod(0o644)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.os.access", return_value=False), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"./uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
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 == 3
|
||||
assert (
|
||||
"Installer path ./uv is not an executable file; fix the path or reinstall it and retry."
|
||||
in out
|
||||
)
|
||||
assert "Installer ./uv is not executable" not in out
|
||||
|
||||
def test_real_installer_exit_126_is_not_treated_as_invalid_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(126)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 126
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 126." in out
|
||||
assert "not an executable file" not in out
|
||||
|
||||
def test_absolute_installer_path_missing_gets_path_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "missing-installer" / "uv"
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
assert (
|
||||
f"Installer path {fake_uv} no longer exists; reinstall it and retry."
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_exec_oserror_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_bare_invalid_installer_message_does_not_call_it_a_path(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
"uv",
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run",
|
||||
side_effect=PermissionError("Permission denied"),
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert "Installer uv is not executable" in out
|
||||
assert "Installer path uv" not in out
|
||||
|
||||
def test_exec_oserror_errno_is_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
invalid_error = OSError(errno.ENOEXEC, "Exec format error")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=invalid_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 3
|
||||
out = strip_ansi(result.output)
|
||||
assert f"Installer path {fake_uv} is not an executable file" in out
|
||||
assert "not found on PATH" not in out
|
||||
|
||||
def test_transient_exec_oserror_is_not_treated_as_invalid_installer(
|
||||
self, uv_tool_argv0, clean_environ, tmp_path
|
||||
):
|
||||
fake_uv = tmp_path / "installer-bin" / "uv"
|
||||
fake_uv.parent.mkdir()
|
||||
fake_uv.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
fake_uv.chmod(0o755)
|
||||
transient_error = OSError(errno.EMFILE, "Too many open files")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"), patch(
|
||||
"specify_cli._version._assemble_installer_argv",
|
||||
return_value=[
|
||||
str(fake_uv),
|
||||
"tool",
|
||||
"install",
|
||||
"specify-cli",
|
||||
"--force",
|
||||
"--from",
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.6",
|
||||
],
|
||||
), patch("specify_cli._version.subprocess.run", side_effect=transient_error):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
# Transient/unknown OSErrors are re-raised rather than mapped to the
|
||||
# invalid-installer exit 3, so the CLI surfaces them as an uncaught
|
||||
# error: exit code 1 with the original OSError preserved.
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, OSError)
|
||||
|
||||
|
||||
class TestInstallerFailed:
|
||||
"""Installer non-zero exit → propagate code, print rollback hint."""
|
||||
|
||||
def test_installer_exit_2_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 2." in out
|
||||
assert "Try again or run the command manually:" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.6" in out
|
||||
assert (
|
||||
"To pin back to the previous version: "
|
||||
"uv tool install specify-cli --force --from "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
# No verification attempted after a failed installer run.
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_installer_exit_127_propagates(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(127)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 127
|
||||
|
||||
def test_installer_timeout_prints_timeout_specific_message(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "12")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
subprocess.TimeoutExpired(cmd=["uv"], timeout=12)
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." in out
|
||||
assert "SPECIFY_UPGRADE_TIMEOUT_SECS=12" in out
|
||||
|
||||
def test_non_finite_timeout_warns_and_runs_without_timeout(
|
||||
self, uv_tool_argv0, clean_environ, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SPECIFY_UPGRADE_TIMEOUT_SECS", "nan")
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Ignoring invalid SPECIFY_UPGRADE_TIMEOUT_SECS='nan'" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
assert mock_run.call_args_list[0].kwargs["timeout"] is None
|
||||
|
||||
def test_real_installer_exit_124_is_not_treated_as_timeout(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(124)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 124
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrade failed. Installer exit code: 124." in out
|
||||
assert "Upgrade timed out while waiting for the installer subprocess." not in out
|
||||
|
||||
def test_pipx_failure_prints_pipx_rollback_hint(self, pipx_argv0, clean_environ):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="pipx"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: pipx install --force "
|
||||
"git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
|
||||
def test_rollback_hint_accepts_normalizable_stable_snapshot(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="v0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert (
|
||||
"To pin back to the previous version: uv tool install specify-cli --force "
|
||||
"--from git+https://github.com/github/spec-kit.git@v0.7.5"
|
||||
) in out
|
||||
assert "Previous version was not an exact stable release tag" not in out
|
||||
|
||||
def test_prerelease_failure_degrades_rollback_hint_to_releases_page(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0rc1"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v1.0.0"})
|
||||
mock_run.side_effect = [_completed_process(2)]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Previous version was not an exact stable release tag" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
assert "git+https://github.com/github/spec-kit.git@v1.0.0rc1" not in out
|
||||
184
tests/test_self_upgrade_guidance.py
Normal file
184
tests/test_self_upgrade_guidance.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""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)
|
||||
649
tests/test_self_upgrade_verification.py
Normal file
649
tests/test_self_upgrade_verification.py
Normal file
@@ -0,0 +1,649 @@
|
||||
"""Verification, resolution, and validation tests for `specify self upgrade`."""
|
||||
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import specify_cli
|
||||
from specify_cli import app
|
||||
|
||||
from tests.self_upgrade_helpers import (
|
||||
SENTINEL_GH_TOKEN,
|
||||
SENTINEL_GITHUB_TOKEN,
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_completed_process,
|
||||
_verify_upgrade,
|
||||
mock_urlopen_response,
|
||||
runner,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 6 — User Story 4: failure recovery (P2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVerificationMismatch:
|
||||
"""Installer says 0 but the binary is still the old version → exit 2."""
|
||||
|
||||
def test_installer_ok_but_verify_returns_old_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0), # installer OK
|
||||
_completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD!
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "resolves to 0.7.5 (expected v0.7.6)" in out
|
||||
assert "The new version may take effect on your next invocation." in out
|
||||
|
||||
def test_verify_nonzero_exit_is_not_treated_as_success(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(1, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_accepts_pep440_equivalent_rc_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.9.0"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 1.0.0rc1\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_specify_cli_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify-cli version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_accepts_capitalized_binary_name_in_version_output(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="Specify, version 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_verify_rejects_output_without_parseable_version(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify version unknown\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Verification failed" in out
|
||||
assert "(unknown) (expected v0.7.6)" in out
|
||||
|
||||
def test_verify_uses_current_entrypoint_when_not_on_path(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
assert uv_tool_argv0.exists()
|
||||
assert uv_tool_argv0.is_file()
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
||||
), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(uv_tool_argv0)
|
||||
assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS
|
||||
|
||||
def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which",
|
||||
side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None,
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.os.access", return_value=False
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_ignores_python_entrypoint_and_falls_back_to_specify(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_python = tmp_path / "python3"
|
||||
fake_python.write_text("#!/bin/sh\n")
|
||||
fake_python.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version.sys.argv", [str(fake_python)]
|
||||
), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
||||
|
||||
def test_verify_accepts_specify_cli_named_current_entrypoint(
|
||||
self,
|
||||
clean_environ,
|
||||
tmp_path,
|
||||
):
|
||||
fake_specify_cli = tmp_path / "specify-cli"
|
||||
fake_specify_cli.write_text("#!/bin/sh\n")
|
||||
fake_specify_cli.chmod(0o755)
|
||||
|
||||
plan = _UpgradePlan(
|
||||
method=_InstallMethod.UV_TOOL,
|
||||
current_version="0.7.5",
|
||||
target_tag="v0.7.6",
|
||||
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
||||
preview_summary="",
|
||||
pre_upgrade_snapshot="0.7.5",
|
||||
)
|
||||
|
||||
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch(
|
||||
"specify_cli._version.os.access", return_value=True
|
||||
):
|
||||
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
||||
verified = _verify_upgrade(plan)
|
||||
|
||||
assert verified == "0.7.6"
|
||||
assert mock_run.call_args.args[0][0] == str(fake_specify_cli)
|
||||
|
||||
|
||||
class TestResolutionFailures:
|
||||
"""Pre-installer resolution failure → exit 1, reusing the resolver category strings."""
|
||||
|
||||
def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ):
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("nope"),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output)
|
||||
|
||||
def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=403,
|
||||
msg="rate limited",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert (
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
||||
in strip_ansi(result.output)
|
||||
)
|
||||
|
||||
def test_http_500_exits_1(self, uv_tool_argv0, clean_environ):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=500,
|
||||
msg="srv err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code, expected",
|
||||
[
|
||||
# 429 (Too Many Requests / secondary rate limit) gets the same
|
||||
# actionable token hint as 403; other statuses surface verbatim.
|
||||
(
|
||||
429,
|
||||
"Upgrade aborted: rate limited (configure ~/.specify/auth.json "
|
||||
"with a GitHub token)",
|
||||
),
|
||||
(404, "Upgrade aborted: HTTP 404"),
|
||||
(502, "Upgrade aborted: HTTP 502"),
|
||||
],
|
||||
)
|
||||
def test_http_error_categorization(
|
||||
self, code, expected, uv_tool_argv0, clean_environ
|
||||
):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://api.github.com",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs={}, # type: ignore[arg-type]
|
||||
fp=None,
|
||||
)
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 1
|
||||
assert expected in strip_ansi(result.output)
|
||||
|
||||
def test_unparseable_resolved_release_tag_exits_1_without_traceback(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.subprocess.run"
|
||||
) as mock_run, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
out = strip_ansi(result.output)
|
||||
assert "resolved release tag is not a comparable version" in out
|
||||
assert "release-main" not in out
|
||||
assert "Traceback" not in out
|
||||
assert mock_run.call_count == 0
|
||||
|
||||
|
||||
class TestTagValidation:
|
||||
"""--tag regex enforcement."""
|
||||
|
||||
def test_valid_stable_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0.dev0" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_rc_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="1.0.0b1"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--tag", "v1.0.0-beta.1"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Already on requested release: v1.0.0-beta.1" in strip_ansi(
|
||||
result.output
|
||||
)
|
||||
|
||||
def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ):
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.8.0+build.42" in strip_ansi(result.output)
|
||||
|
||||
def test_uppercase_v_prefix_is_folded_to_lowercase(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# A pasted uppercase `V` prefix is accepted and normalized to `v` so
|
||||
# the git ref matches the canonical lowercase release tag.
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "V0.7.6"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v0.7.6" in strip_ansi(result.output)
|
||||
|
||||
def test_valid_prerelease_with_build_metadata_tag(
|
||||
self, uv_tool_argv0, clean_environ
|
||||
):
|
||||
# Prerelease and build-metadata suffixes compose (PEP 440 / semver).
|
||||
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_tag",
|
||||
[
|
||||
"latest",
|
||||
"0.7.5",
|
||||
"main",
|
||||
"v7",
|
||||
"",
|
||||
"v1.2.3abc",
|
||||
"v1.2.3...",
|
||||
"v1.2.3++",
|
||||
"v\uff11.2.3",
|
||||
"v1.\u0662.3",
|
||||
],
|
||||
)
|
||||
def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
|
||||
result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag])
|
||||
assert result.exit_code == 1
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
||||
|
||||
|
||||
class TestUnknownCurrent:
|
||||
"""'unknown' current version renders literally in notice and success message."""
|
||||
|
||||
def test_unknown_current_renders_literal_in_notice(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
out = strip_ansi(result.output)
|
||||
assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out
|
||||
assert "Upgraded specify-cli: unknown → 0.7.6" in out
|
||||
|
||||
def test_unknown_current_rollback_hint_degrades(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
clean_environ,
|
||||
):
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="unknown"
|
||||
):
|
||||
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
mock_run.side_effect = [_completed_process(2)] # installer fails
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert result.exit_code == 2
|
||||
out = strip_ansi(result.output)
|
||||
assert "Could not determine the previous version" in out
|
||||
assert "https://github.com/github/spec-kit/releases" in out
|
||||
|
||||
|
||||
class TestTokenScrubbing:
|
||||
"""GH_TOKEN / GITHUB_TOKEN are stripped from every child env."""
|
||||
|
||||
def test_env_passed_to_subprocess_has_no_github_tokens(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}"
|
||||
assert "GITHUB_TOKEN" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_is_case_insensitive(
|
||||
self,
|
||||
uv_tool_argv0,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN)
|
||||
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
||||
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener"
|
||||
) as mock_build_opener, patch(
|
||||
"specify_cli._version.shutil.which", return_value="uv"
|
||||
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
||||
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
||||
):
|
||||
mock_urlopen.return_value = response
|
||||
mock_build_opener.return_value.open.return_value = response
|
||||
mock_run.side_effect = [
|
||||
_completed_process(0),
|
||||
_completed_process(0, stdout="specify 0.7.6\n"),
|
||||
]
|
||||
runner.invoke(app, ["self", "upgrade"])
|
||||
|
||||
assert mock_run.call_count >= 1
|
||||
for call in mock_run.call_args_list:
|
||||
env_kwarg = call.kwargs.get("env") or {}
|
||||
assert "gh_token" not in env_kwarg
|
||||
assert "GitHub_Token" not in env_kwarg
|
||||
for v in env_kwarg.values():
|
||||
assert SENTINEL_GH_TOKEN not in v
|
||||
assert SENTINEL_GITHUB_TOKEN not in v
|
||||
|
||||
def test_env_scrubbing_removes_github_token_variants(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_PAT", "gh-pat")
|
||||
monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret")
|
||||
monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key")
|
||||
monkeypatch.setenv("GITHUB_PAT", "github-pat")
|
||||
monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path")
|
||||
monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github")
|
||||
monkeypatch.setenv("GITHUB_API_TOKEN", "api-token")
|
||||
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key")
|
||||
monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret")
|
||||
monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token")
|
||||
monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept")
|
||||
monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept")
|
||||
monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept")
|
||||
monkeypatch.setenv("UNRELATED_TOKEN", "kept")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_PAT" not in env
|
||||
assert "GH_TOKEN_FILE" not in env
|
||||
assert "GH_ENTERPRISE_TOKEN" not in env
|
||||
assert "GH_ENTERPRISE_SECRET" not in env
|
||||
assert "GH_ENTERPRISE_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_PAT" not in env
|
||||
assert "GITHUB_TOKEN_PATH" not in env
|
||||
assert "GITHUB_ENTERPRISE_TOKEN" not in env
|
||||
assert "GITHUB_API_TOKEN" not in env
|
||||
assert "GITHUB_APP_PRIVATE_KEY" not in env
|
||||
assert "GITHUB_OAUTH_CLIENT_SECRET" not in env
|
||||
assert "HOMEBREW_GITHUB_API_TOKEN" not in env
|
||||
assert env["NOTGITHUB_TOKEN"] == "not-github-kept"
|
||||
assert env["GHOST_API_TOKEN"] == "ghost-kept"
|
||||
assert env["GHIDRA_API_KEY"] == "ghidra-kept"
|
||||
assert env["UNRELATED_TOKEN"] == "kept"
|
||||
|
||||
def test_env_scrubbing_strips_noncredential_github_vars_by_design(
|
||||
self, monkeypatch
|
||||
):
|
||||
# The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is
|
||||
# removed from the installer subprocess env, including non-credential
|
||||
# context vars. This is a deliberate fail-safe so credential-adjacent
|
||||
# names that lack a recognized suffix (e.g. GH_TOKEN_FILE,
|
||||
# GITHUB_TOKEN_PATH, asserted above) can never leak. The installer
|
||||
# (`uv tool install` / `pipx install` of a public package) does not
|
||||
# consume routing/context vars like GITHUB_REPOSITORY, so nothing the
|
||||
# subprocess needs is lost by stripping them.
|
||||
monkeypatch.setenv("GH_HOST", "github.example.com")
|
||||
monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh")
|
||||
monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit")
|
||||
monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work")
|
||||
monkeypatch.setenv("GITHUB_USER", "octocat")
|
||||
|
||||
env = specify_cli._version._scrubbed_env()
|
||||
|
||||
assert "GH_HOST" not in env
|
||||
assert "GH_CONFIG_DIR" not in env
|
||||
assert "GITHUB_REPOSITORY" not in env
|
||||
assert "GITHUB_WORKSPACE" not in env
|
||||
assert "GITHUB_USER" not in env
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Tests for the `specify self` sub-app (`self check` and `self upgrade`).
|
||||
|
||||
Network isolation contract (SC-004 / FR-014): every test that exercises
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock
|
||||
`urllib.request.urlopen` so no real outbound call ever reaches
|
||||
api.github.com. The `self upgrade` stub tests do not need that patch because
|
||||
the stub is contractually network-free. Run this module under `pytest-socket`
|
||||
(if installed) with `--disable-socket` as an extra safety net.
|
||||
`specify self check` or `_fetch_latest_release_tag()` MUST mock the outbound
|
||||
urllib path it expects (`urlopen` for unauthenticated requests, `build_opener`
|
||||
for authenticated requests) so no real outbound call ever reaches api.github.com.
|
||||
Tests for non-network `self upgrade` behavior should keep that contract explicit
|
||||
with local mocks. Run this module under `pytest-socket` (if installed) with
|
||||
`--disable-socket` as an extra safety net.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import importlib.metadata
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -24,6 +24,7 @@ from specify_cli._version import (
|
||||
_normalize_tag,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -35,16 +36,6 @@ _RATE_LIMITED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
def _mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
|
||||
|
||||
def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
return urllib.error.HTTPError(
|
||||
url="https://api.github.com/repos/github/spec-kit/releases/latest",
|
||||
@@ -55,39 +46,6 @@ def _http_error(code: int, message: str = "error") -> urllib.error.HTTPError:
|
||||
)
|
||||
|
||||
|
||||
class TestSelfUpgradeStub:
|
||||
"""Pins the `specify self upgrade` stub output + exit code (contract §3.5, FR-016)."""
|
||||
|
||||
def test_prints_exactly_three_lines_and_exits_zero(self):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
lines = strip_ansi(result.output).strip().splitlines()
|
||||
assert lines == [
|
||||
"specify self upgrade is not implemented yet.",
|
||||
"Run 'specify self check' to see whether a newer release is available.",
|
||||
"Actual self-upgrade is planned as follow-up work.",
|
||||
]
|
||||
|
||||
def test_stub_makes_no_network_call(self):
|
||||
# The stub must not hit the network via either urllib path:
|
||||
# unauthenticated requests use urlopen() directly; authenticated ones
|
||||
# go through build_opener(...).open(). Both are patched so that any
|
||||
# accidental network call raises immediately.
|
||||
network_error = AssertionError("stub must not hit the network")
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=network_error,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
side_effect=network_error,
|
||||
),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "upgrade"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestIsNewer:
|
||||
def test_latest_strictly_greater_returns_true(self):
|
||||
assert _is_newer("0.8.0", "0.7.4") is True
|
||||
@@ -151,7 +109,7 @@ class TestUserStory1:
|
||||
def test_newer_available_prints_update_and_install_command(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -164,7 +122,7 @@ class TestUserStory1:
|
||||
def test_up_to_date_prints_current_only(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.9.0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -176,7 +134,7 @@ class TestUserStory1:
|
||||
def test_dev_build_ahead_of_release_is_up_to_date(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.5.dev0"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
@@ -187,26 +145,46 @@ class TestUserStory1:
|
||||
def test_unknown_installed_still_prints_latest_and_reinstall(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.7.4"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Current version could not be determined" in output
|
||||
assert "Latest release: v0.7.4" in output
|
||||
assert "0.7.4" in output
|
||||
assert "git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
assert "specify self upgrade" in output
|
||||
assert "pipx install --force git+https://github.com/github/spec-kit.git@v0.7.4" in output
|
||||
|
||||
def test_unparseable_tag_routes_to_indeterminate(self):
|
||||
def test_unknown_installed_uses_placeholder_when_latest_tag_is_invalid(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="unknown"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=mock_urlopen_response({"tag_name": "v0.9.0;echo unsafe"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
assert "v0.9.0;echo unsafe" not in output
|
||||
|
||||
def test_unparseable_tag_reports_validation_failure_without_raw_tag(self):
|
||||
with patch("specify_cli._version._get_installed_version", return_value="0.7.4"), patch(
|
||||
"specify_cli.authentication.http.urllib.request.urlopen",
|
||||
return_value=_mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
return_value=mock_urlopen_response({"tag_name": "not-a-version"}),
|
||||
):
|
||||
result = runner.invoke(app, ["self", "check"])
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert "Update available" not in output
|
||||
assert "Up to date" in output
|
||||
assert "Up to date" not in output
|
||||
assert "Could not validate latest release tag from GitHub." in output
|
||||
assert "Latest release: vX.Y.Z" in output
|
||||
assert "0.7.4" in output
|
||||
assert "not-a-version" not in output
|
||||
assert "git+https://github.com/github/spec-kit.git@vX.Y.Z" in output
|
||||
|
||||
|
||||
class TestFailureCategorization:
|
||||
@@ -306,13 +284,25 @@ class TestUserStory2:
|
||||
def _capture_request_via_urlopen():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, timeout=None):
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
captured["request"] = req
|
||||
return _mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
return captured, _side_effect
|
||||
|
||||
|
||||
def _capture_request_via_auth_opener():
|
||||
captured = {}
|
||||
|
||||
def _side_effect(req, *args, **kwargs):
|
||||
captured["request"] = req
|
||||
return mock_urlopen_response({"tag_name": "v0.7.4"})
|
||||
|
||||
opener = MagicMock()
|
||||
opener.open.side_effect = _side_effect
|
||||
return captured, opener
|
||||
|
||||
|
||||
def _inject_github_config(monkeypatch, token_env="GH_TOKEN"):
|
||||
from tests.auth_helpers import inject_github_config
|
||||
inject_github_config(monkeypatch, token_env)
|
||||
@@ -323,10 +313,11 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
_inject_github_config(monkeypatch, token_env="GH_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GH_TOKEN}"
|
||||
@@ -335,10 +326,11 @@ class TestUserStory3:
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
@@ -376,10 +368,11 @@ class TestUserStory3:
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
||||
_inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
|
||||
captured, side_effect = _capture_request_via_urlopen()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
captured, opener = _capture_request_via_auth_opener()
|
||||
with patch(
|
||||
"specify_cli.authentication.http.urllib.request.build_opener",
|
||||
return_value=opener,
|
||||
):
|
||||
_fetch_latest_release_tag()
|
||||
req = captured["request"]
|
||||
assert req.get_header("Authorization") == f"Bearer {SENTINEL_GITHUB_TOKEN}"
|
||||
|
||||
Reference in New Issue
Block a user