mirror of
https://github.com/usestrix/strix.git
synced 2026-09-30 01:52:18 +00:00
Review feedback on the conflict guidance, both valid. The pipx hint keyed off `.local/bin` appearing in the path, which is where a uv-managed or hand-placed strix lands too. Telling that user to `pipx uninstall strix-agent` either does nothing or removes a different package, and leaves the real PATH conflict in place. Ask pipx instead: compare the executable's directory against `PIPX_BIN_DIR` and confirm `strix-agent` is in `pipx list`. The `rm` suggestion printed the path bare, so copying it would split on spaces or expand a glob. Print it through `printf '%q'`. The decoy tests asserted that pipx was never invoked at all, which the ownership check now legitimately does. They assert no `uninstall` instead, which is the contract that matters. `describe_removal` only runs when another executable wins PATH resolution, which a successful install prevents, so the advice itself is now tested by lifting the functions out of the script and calling them directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
11 KiB
Python
344 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
from pathlib import Path
|
|
from typing import NamedTuple
|
|
|
|
import pytest
|
|
|
|
|
|
DECOY_CONTENT = "#!/bin/sh\nprintf 'other-strix 1.2.3\\n'\n"
|
|
|
|
RELEASE_VERSION = "9.9.9"
|
|
RELEASE_TARGET = "linux-arm64"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="scripts/install.sh is a POSIX shell installer",
|
|
)
|
|
|
|
|
|
def _write_executable(path: Path, content: str) -> None:
|
|
path.write_text(content, encoding="utf-8")
|
|
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
|
|
|
|
|
def _create_release_archive(tmp_path: Path) -> Path:
|
|
binary_name = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}"
|
|
binary_path = tmp_path / binary_name
|
|
_write_executable(binary_path, f"#!/bin/sh\nprintf 'strix {RELEASE_VERSION}\\n'\n")
|
|
|
|
archive_path = tmp_path / f"{binary_name}.tar.gz"
|
|
with tarfile.open(archive_path, "w:gz") as archive:
|
|
archive.add(binary_path, arcname=binary_name)
|
|
return archive_path
|
|
|
|
|
|
def _create_mock_commands(tmp_path: Path, machine: str) -> Path:
|
|
mock_bin = tmp_path / "mock-bin"
|
|
mock_bin.mkdir()
|
|
_write_executable(
|
|
mock_bin / "uname",
|
|
f"""#!/bin/sh
|
|
case "$1" in
|
|
-s) echo Linux ;;
|
|
-m) echo {machine} ;;
|
|
*) echo "unexpected uname argument: $*" >&2; exit 1 ;;
|
|
esac
|
|
""",
|
|
)
|
|
_write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n")
|
|
_write_executable(
|
|
mock_bin / "curl",
|
|
"""#!/bin/sh
|
|
output=""
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "-o" ]; then
|
|
output="$2"
|
|
shift 2
|
|
continue
|
|
fi
|
|
printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG"
|
|
shift
|
|
done
|
|
cp "$STRIX_TEST_ARCHIVE" "$output"
|
|
""",
|
|
)
|
|
return mock_bin
|
|
|
|
|
|
def _create_installer_environment(
|
|
tmp_path: Path,
|
|
archive_path: Path,
|
|
mock_bin: Path,
|
|
) -> tuple[dict[str, str], Path, Path]:
|
|
"""Build the installer environment explicitly.
|
|
|
|
Every variable the installer reads is listed here, so no inherited value
|
|
(`XDG_CONFIG_HOME`, `GITHUB_ACTIONS`, `TMPDIR`, ...) can send a write
|
|
outside the sandbox or change the code path under test.
|
|
"""
|
|
home_path = tmp_path / "home"
|
|
home_path.mkdir()
|
|
download_path = tmp_path / "downloads"
|
|
download_path.mkdir()
|
|
curl_log_path = tmp_path / "curl.log"
|
|
environment = {
|
|
"HOME": str(home_path),
|
|
"XDG_CONFIG_HOME": str(home_path / ".config"),
|
|
"PATH": f"{mock_bin}:/usr/bin:/bin",
|
|
"SHELL": "/bin/bash",
|
|
"TMPDIR": str(download_path),
|
|
"STRIX_TEST_ARCHIVE": str(archive_path),
|
|
"STRIX_TEST_CURL_LOG": str(curl_log_path),
|
|
"VERSION": RELEASE_VERSION,
|
|
}
|
|
return environment, home_path, curl_log_path
|
|
|
|
|
|
def _run_installer(
|
|
repository_root: Path,
|
|
environment: dict[str, str],
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run( # noqa: S603
|
|
["/bin/bash", str(repository_root / "scripts/install.sh")],
|
|
cwd=repository_root,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> None:
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
archive_path = _create_release_archive(tmp_path)
|
|
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
|
environment, home_path, curl_log_path = _create_installer_environment(
|
|
tmp_path,
|
|
archive_path,
|
|
mock_bin,
|
|
)
|
|
|
|
result = _run_installer(repository_root, environment)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz"
|
|
assert expected_filename in curl_log_path.read_text(encoding="utf-8")
|
|
|
|
installed_binary = home_path / ".strix/bin/strix"
|
|
installed_result = subprocess.run( # noqa: S603
|
|
[str(installed_binary), "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
assert installed_result.stdout.strip() == f"strix {RELEASE_VERSION}"
|
|
|
|
|
|
def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None:
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
archive_path = _create_release_archive(tmp_path)
|
|
mock_bin = _create_mock_commands(tmp_path, machine="riscv64")
|
|
environment, home_path, curl_log_path = _create_installer_environment(
|
|
tmp_path,
|
|
archive_path,
|
|
mock_bin,
|
|
)
|
|
|
|
result = _run_installer(repository_root, environment)
|
|
|
|
assert result.returncode != 0
|
|
assert "Unsupported OS/Arch: linux/riscv64" in result.stdout
|
|
assert not curl_log_path.exists()
|
|
assert not (home_path / ".strix").exists()
|
|
|
|
|
|
class _DecoyRun(NamedTuple):
|
|
decoy_path: Path
|
|
pipx_log_path: Path
|
|
home_path: Path
|
|
stdout: str
|
|
|
|
|
|
def _install_with_decoy(
|
|
tmp_path: Path,
|
|
decoy_directory_name: str,
|
|
*,
|
|
pipx_owns_it: bool = False,
|
|
) -> _DecoyRun:
|
|
"""Run the installer with an unrelated `strix` ahead of it on `PATH`.
|
|
|
|
The mock `pipx` answers the two ownership questions the installer asks and
|
|
records every invocation, so a test can tell an ownership *query* apart
|
|
from an `uninstall`.
|
|
"""
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
archive_path = _create_release_archive(tmp_path)
|
|
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
|
|
|
decoy_directory = tmp_path / decoy_directory_name
|
|
decoy_directory.mkdir(parents=True)
|
|
decoy_path = decoy_directory / "strix"
|
|
_write_executable(decoy_path, DECOY_CONTENT)
|
|
|
|
# When pipx "owns" the decoy it reports the decoy's own directory as its
|
|
# bin dir and lists the package; otherwise it points somewhere else.
|
|
pipx_bin_dir = decoy_directory if pipx_owns_it else tmp_path / "elsewhere"
|
|
pipx_listing = "strix-agent 1.2.3" if pipx_owns_it else ""
|
|
pipx_log_path = tmp_path / "pipx.log"
|
|
_write_executable(
|
|
mock_bin / "pipx",
|
|
f"""#!/bin/sh
|
|
printf '%s\n' "$*" >> "{pipx_log_path}"
|
|
case "$1 $2" in
|
|
"environment --value") printf '%s\n' "{pipx_bin_dir}" ;;
|
|
"list --short") printf '%s' "{pipx_listing}" ;;
|
|
esac
|
|
""",
|
|
)
|
|
|
|
environment, home_path, _ = _create_installer_environment(tmp_path, archive_path, mock_bin)
|
|
environment["PATH"] = f"{decoy_directory}:{environment['PATH']}"
|
|
|
|
result = _run_installer(repository_root, environment)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
return _DecoyRun(decoy_path, pipx_log_path, home_path, result.stdout)
|
|
|
|
|
|
def _pipx_invocations(pipx_log_path: Path) -> list[str]:
|
|
if not pipx_log_path.exists():
|
|
return []
|
|
return pipx_log_path.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
def test_installer_leaves_unrelated_strix_executables_alone(tmp_path: Path) -> None:
|
|
run = _install_with_decoy(tmp_path, "other-bin")
|
|
|
|
assert run.decoy_path.exists()
|
|
assert run.decoy_path.read_text(encoding="utf-8") == DECOY_CONTENT
|
|
assert (run.home_path / ".strix/bin/strix").exists()
|
|
|
|
|
|
def test_installer_does_not_uninstall_a_pipx_managed_strix(tmp_path: Path) -> None:
|
|
run = _install_with_decoy(tmp_path, ".local/bin", pipx_owns_it=True)
|
|
|
|
assert run.decoy_path.exists()
|
|
assert (run.home_path / ".strix/bin/strix").exists()
|
|
# Querying pipx about ownership is fine; changing anything is not.
|
|
assert not any(line.startswith("uninstall") for line in _pipx_invocations(run.pipx_log_path))
|
|
|
|
|
|
def _extract_shell_functions(repository_root: Path, names: tuple[str, ...]) -> str:
|
|
"""Pull named function definitions out of the installer.
|
|
|
|
`describe_removal` only runs when another executable wins PATH resolution,
|
|
which a successful install prevents, so driving it through a full install
|
|
would assert nothing. Lift the functions out and call them directly.
|
|
"""
|
|
source = (repository_root / "scripts/install.sh").read_text(encoding="utf-8")
|
|
blocks = []
|
|
for name in names:
|
|
opening = f"{name}() {{\n"
|
|
begin = source.index(opening)
|
|
end = source.index("\n}\n", begin) + len("\n}\n")
|
|
blocks.append(source[begin:end])
|
|
return "".join(blocks)
|
|
|
|
|
|
def _describe_removal(
|
|
tmp_path: Path,
|
|
path: str,
|
|
*,
|
|
pipx_bin_dir: str | None = None,
|
|
pipx_listing: str = "",
|
|
) -> str:
|
|
"""Run the installer's `describe_removal` against a mock `pipx`."""
|
|
repository_root = Path(__file__).resolve().parents[1]
|
|
mock_bin = tmp_path / "fn-bin"
|
|
mock_bin.mkdir()
|
|
if pipx_bin_dir is not None:
|
|
_write_executable(
|
|
mock_bin / "pipx",
|
|
f"""#!/bin/sh
|
|
case "$1 $2" in
|
|
"environment --value") printf '%s\\n' "{pipx_bin_dir}" ;;
|
|
"list --short") printf '%s' "{pipx_listing}" ;;
|
|
esac
|
|
""",
|
|
)
|
|
|
|
script = tmp_path / "describe.sh"
|
|
script.write_text(
|
|
"set -euo pipefail\nMUTED=''\nNC=''\n"
|
|
+ _extract_shell_functions(repository_root, ("pipx_owns", "describe_removal"))
|
|
+ f'\ndescribe_removal "{path}"\n',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = subprocess.run( # noqa: S603
|
|
["/bin/bash", str(script)],
|
|
env={"PATH": f"{mock_bin}:/usr/bin:/bin"},
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def test_removal_advice_names_pipx_only_when_pipx_owns_the_executable(tmp_path: Path) -> None:
|
|
pipx_bin = tmp_path / "pipx-bin"
|
|
owned = _describe_removal(
|
|
tmp_path,
|
|
str(pipx_bin / "strix"),
|
|
pipx_bin_dir=str(pipx_bin),
|
|
pipx_listing="strix-agent 1.2.3",
|
|
)
|
|
assert "pipx uninstall strix-agent" in owned
|
|
|
|
|
|
def test_removal_advice_does_not_claim_pipx_owns_an_unrelated_local_bin_strix(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A uv-managed or hand-placed strix in ~/.local/bin is not a pipx install.
|
|
|
|
The old heuristic keyed off `.local/bin` appearing in the path, so it would
|
|
tell the user to `pipx uninstall strix-agent` for a file pipx never
|
|
installed. That either does nothing or removes a different package, and
|
|
leaves the real PATH conflict in place.
|
|
"""
|
|
local_bin = tmp_path / ".local" / "bin"
|
|
advice = _describe_removal(
|
|
tmp_path,
|
|
str(local_bin / "strix"),
|
|
pipx_bin_dir=str(tmp_path / "elsewhere"),
|
|
)
|
|
assert "pipx uninstall" not in advice
|
|
assert "rm " in advice
|
|
|
|
|
|
def test_removal_advice_reports_rm_when_pipx_is_absent(tmp_path: Path) -> None:
|
|
advice = _describe_removal(tmp_path, str(tmp_path / ".local" / "bin" / "strix"))
|
|
assert "pipx" not in advice
|
|
assert "rm " in advice
|
|
|
|
|
|
def test_removal_advice_quotes_paths_with_spaces_and_globs(tmp_path: Path) -> None:
|
|
"""A copied `rm` must survive spaces and globbing characters in the path."""
|
|
awkward = tmp_path / "od d bin" / "strix*"
|
|
advice = _describe_removal(tmp_path, str(awkward))
|
|
|
|
assert f"rm {awkward}" not in advice, "the bare path was printed unquoted"
|
|
command = advice.split("rm ", 1)[1].strip()
|
|
# Re-expanding the printed argument must yield exactly the original path.
|
|
expanded = subprocess.run( # noqa: S603
|
|
["/bin/bash", "-c", f"printf '%s' {command}"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
assert expanded.stdout == str(awkward)
|