mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 14:39:00 +08:00
* feat(scripts): add Python check-prerequisites PoC * fix(scripts): address check-prerequisites parity feedback * test(scripts): label PowerShell prerequisite parity cases --------- Co-authored-by: root <kinsonnee@gmail.com>
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""Parity tests for the Python check-prerequisites PoC."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import requires_bash
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
|
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
|
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
|
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
|
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
|
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
|
|
|
HAS_PWSH = shutil.which("pwsh") is not None
|
|
_WINDOWS_POWERSHELL = (
|
|
shutil.which("powershell.exe") or shutil.which("powershell")
|
|
) if os.name == "nt" else None
|
|
|
|
|
|
def _install_scripts(repo: Path) -> None:
|
|
bash_dir = repo / ".specify" / "scripts" / "bash"
|
|
bash_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
|
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
|
|
|
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
|
ps_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
|
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
|
|
|
py_dir = repo / ".specify" / "scripts" / "python"
|
|
py_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy(COMMON_PY, py_dir / "common.py")
|
|
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
|
|
|
|
|
def _write_feature_json(
|
|
repo: Path, feature_directory: str = "specs/001-my-feature"
|
|
) -> None:
|
|
(repo / ".specify" / "feature.json").write_text(
|
|
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _clean_env() -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
for key in list(env):
|
|
if key.startswith("SPECIFY_"):
|
|
env.pop(key)
|
|
return env
|
|
|
|
|
|
def _git_init(repo: Path) -> None:
|
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
subprocess.run(
|
|
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
|
)
|
|
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
|
subprocess.run(
|
|
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def prereq_repo(tmp_path: Path) -> Path:
|
|
repo = tmp_path / "proj"
|
|
repo.mkdir()
|
|
_git_init(repo)
|
|
(repo / ".specify").mkdir()
|
|
_install_scripts(repo)
|
|
return repo
|
|
|
|
|
|
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
|
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
|
return [sys.executable, str(script), *args]
|
|
|
|
|
|
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
|
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
|
return [sys.executable, str(script), *args]
|
|
|
|
|
|
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
|
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
|
return ["bash", str(script), *args]
|
|
|
|
|
|
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
|
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
|
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
|
return [exe, "-NoProfile", "-File", str(script), *args]
|
|
|
|
|
|
def _run(
|
|
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
cmd,
|
|
cwd=repo,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
env=env or _clean_env(),
|
|
)
|
|
|
|
|
|
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def _normalize_status_text(text: str) -> str:
|
|
return (
|
|
text.replace(" ✓ ", " [OK] ")
|
|
.replace(" ✗ ", " [FAIL] ")
|
|
.replace("\r\n", "\n")
|
|
)
|
|
|
|
|
|
def _normalize_help_text(text: str) -> str:
|
|
normalized = text.replace("\r\n", "\n").replace(
|
|
"check-prerequisites.sh", "check_prerequisites.py"
|
|
)
|
|
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
|
|
|
|
|
@requires_bash
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
("--json",),
|
|
("--json", "--include-tasks"),
|
|
("--json", "--require-tasks", "--include-tasks"),
|
|
("--json", "--paths-only"),
|
|
],
|
|
)
|
|
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
|
feat = prereq_repo / "specs" / "001-my-feature"
|
|
feat.mkdir(parents=True)
|
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
|
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
|
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
|
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
|
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
|
(feat / "contracts" / "v1").mkdir(parents=True)
|
|
_write_feature_json(prereq_repo)
|
|
|
|
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
|
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert py.stderr == bash.stderr == ""
|
|
assert _json_stdout(py) == _json_stdout(bash)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
|
feat = prereq_repo / "specs" / "001-my-feature"
|
|
feat.mkdir(parents=True)
|
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
|
(feat / "contracts").mkdir()
|
|
_write_feature_json(prereq_repo)
|
|
|
|
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
|
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert py.stderr == bash.stderr == ""
|
|
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
|
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
|
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
|
|
|
assert py.returncode == bash.returncode == 0
|
|
assert py.stderr == bash.stderr == ""
|
|
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
|
|
|
|
|
@requires_bash
|
|
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
|
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
|
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
|
|
|
assert py.returncode == bash.returncode == 1
|
|
assert py.stdout == bash.stdout == ""
|
|
assert py.stderr == bash.stderr
|
|
|
|
|
|
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
|
@pytest.mark.parametrize(
|
|
("py_args", "ps_args"),
|
|
[
|
|
(("--json",), ("-Json",)),
|
|
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
|
(
|
|
("--json", "--require-tasks", "--include-tasks"),
|
|
("-Json", "-RequireTasks", "-IncludeTasks"),
|
|
),
|
|
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
|
],
|
|
ids=[
|
|
"json",
|
|
"json_include_tasks",
|
|
"json_require_tasks_include_tasks",
|
|
"json_paths_only",
|
|
],
|
|
)
|
|
def test_python_json_output_matches_powershell(
|
|
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
|
) -> None:
|
|
feat = prereq_repo / "specs" / "001-my-feature"
|
|
feat.mkdir(parents=True)
|
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
|
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
|
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
|
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
|
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
|
(feat / "contracts" / "v1").mkdir(parents=True)
|
|
_write_feature_json(prereq_repo)
|
|
|
|
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
|
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
|
|
|
assert py.returncode == ps.returncode == 0
|
|
assert py.stderr == ps.stderr == ""
|
|
assert _json_stdout(py) == _json_stdout(ps)
|
|
|
|
|
|
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
|
repo = tmp_path / "proj"
|
|
outside = tmp_path / "outside"
|
|
repo.mkdir()
|
|
outside.mkdir()
|
|
_git_init(repo)
|
|
(repo / ".specify").mkdir()
|
|
_write_feature_json(repo)
|
|
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
|
|
|
py_dir = repo / "scripts" / "python"
|
|
py_dir.mkdir(parents=True)
|
|
shutil.copy(COMMON_PY, py_dir / "common.py")
|
|
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
|
|
|
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
|
|
|
assert py.returncode == 0, py.stderr
|
|
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
|
|
|
|
|
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
|
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
|
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
|
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
|
feature_json = prereq_repo / ".specify" / "feature.json"
|
|
before = feature_json.read_text(encoding="utf-8")
|
|
env = _clean_env()
|
|
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
|
|
|
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
|
|
|
assert py.returncode == 0, py.stderr
|
|
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
|
assert feature_json.read_text(encoding="utf-8") == before
|
|
|
|
|
|
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
|
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
|
feat = prereq_repo / "specs" / "002-other"
|
|
feat.mkdir(parents=True)
|
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
|
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
|
env = _clean_env()
|
|
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
|
|
|
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
|
|
|
assert py.returncode == 0, py.stderr
|
|
data = json.loads(
|
|
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
|
)
|
|
assert data["feature_directory"] == "specs/002-other"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("args", "expected"),
|
|
[
|
|
(("--json",), "Feature directory not found"),
|
|
(("--json",), "plan.md not found"),
|
|
(("--json", "--require-tasks"), "tasks.md not found"),
|
|
],
|
|
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
|
)
|
|
def test_python_negative_errors_are_stderr_only(
|
|
tmp_path: Path, args: tuple[str, ...], expected: str
|
|
) -> None:
|
|
repo = tmp_path / "proj"
|
|
repo.mkdir()
|
|
_git_init(repo)
|
|
(repo / ".specify").mkdir()
|
|
_install_scripts(repo)
|
|
|
|
if expected in {"plan.md not found", "tasks.md not found"}:
|
|
feat = repo / "specs" / "001-my-feature"
|
|
feat.mkdir(parents=True)
|
|
_write_feature_json(repo)
|
|
if expected == "tasks.md not found":
|
|
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
|
|
|
py = _run(_py_cmd(repo, *args), repo)
|
|
|
|
assert py.returncode != 0
|
|
assert expected in py.stderr
|
|
assert expected not in py.stdout
|
|
assert py.stdout.strip() == ""
|
|
|
|
|
|
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
|
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
|
_write_feature_json(prereq_repo)
|
|
|
|
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
|
|
|
assert py.returncode == 0, py.stderr
|
|
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|