Skip to content
Merged
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ jobs:
run: uvx ruff check src/

pytest:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
Expand All @@ -46,5 +47,9 @@ jobs:
- name: Install dependencies
run: uv sync --extra test

# On windows-latest, bash tests auto-skip unless Git-for-Windows
# bash (MSYS2/MINGW) is detected. The WSL launcher is rejected
# because it cannot handle native Windows paths in test fixtures.
# See tests/conftest.py::_has_working_bash() for details.
- name: Run tests
run: uv run pytest
58 changes: 58 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,68 @@
"""Shared test helpers for the Spec Kit test suite."""

import os
import re
import shutil
import subprocess
import sys

import pytest

_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")


def _has_working_bash() -> bool:
"""Check whether a functional native bash is available.

On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
which searches System32 *before* PATH — so it may find the WSL
launcher even when Git-for-Windows bash appears first in PATH via
``shutil.which``. We therefore probe with bare ``"bash"`` (the
same way test helpers invoke it) to get an accurate result.

On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
The WSL launcher is rejected because it runs in a separate Linux
filesystem and cannot handle native Windows paths used by the
test fixtures.

Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
"""
if os.environ.get("SPECKIT_TEST_BASH") == "1":
return True
if shutil.which("bash") is None:
return False
# Probe with bare "bash" — same as the test helpers — so that
# Windows CreateProcess resolution order is respected.
try:
r = subprocess.run(
["bash", "-c", "echo ok"],
capture_output=True, text=True, timeout=5,
)
if r.returncode != 0 or "ok" not in r.stdout:
return False
except (OSError, subprocess.TimeoutExpired):
return False
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
# not the WSL launcher which can't handle native paths.
if sys.platform == "win32":
try:
u = subprocess.run(
["bash", "-c", "uname -s"],
capture_output=True, text=True, timeout=5,
)
kernel = u.stdout.strip().upper()
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
return False
except (OSError, subprocess.TimeoutExpired):
return False
return True


requires_bash = pytest.mark.skipif(
not _has_working_bash(), reason="working bash not available"
)
Comment thread
mnriem marked this conversation as resolved.


def strip_ansi(text: str) -> str:
"""Remove ANSI escape codes from Rich-formatted CLI output."""
return _ANSI_ESCAPE_RE.sub("", text)
6 changes: 6 additions & 0 deletions tests/extensions/git/test_git_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import pytest

from tests.conftest import requires_bash

PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
EXT_BASH = EXT_DIR / "scripts" / "bash"
Expand Down Expand Up @@ -211,6 +213,7 @@ def test_bundled_extension_locator(self):
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────


@requires_bash
class TestInitializeRepoBash:
def test_initializes_git_repo(self, tmp_path: Path):
"""initialize-repo.sh creates a git repo with initial commit."""
Expand Down Expand Up @@ -269,6 +272,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path):
# ── create-new-feature.sh Tests ──────────────────────────────────────────────


@requires_bash
class TestCreateFeatureBash:
def test_creates_branch_sequential(self, tmp_path: Path):
"""Extension create-new-feature.sh creates sequential branch."""
Expand Down Expand Up @@ -376,6 +380,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path):
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────


@requires_bash
class TestAutoCommitBash:
def test_disabled_by_default(self, tmp_path: Path):
"""auto-commit.sh exits silently when config is all false."""
Expand Down Expand Up @@ -527,6 +532,7 @@ def test_enabled_per_command(self, tmp_path: Path):
# ── git-common.sh Tests ──────────────────────────────────────────────────────


@requires_bash
class TestGitCommonBash:
def test_has_git_true(self, tmp_path: Path):
"""has_git returns 0 in a git repo."""
Expand Down
4 changes: 3 additions & 1 deletion tests/integrations/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import json
import sys

import pytest

Expand Down Expand Up @@ -41,8 +42,9 @@ def test_record_file_rejects_parent_traversal(self, tmp_path):

def test_record_file_rejects_absolute_path(self, tmp_path):
m = IntegrationManifest("test", tmp_path)
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
with pytest.raises(ValueError, match="Absolute paths"):
m.record_file("/tmp/escape.txt", "bad")
m.record_file(abs_path, "bad")

def test_record_existing_rejects_parent_traversal(self, tmp_path):
escape = tmp_path.parent / "escape.txt"
Expand Down
3 changes: 3 additions & 0 deletions tests/test_cursor_frontmatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import pytest

from tests.conftest import requires_bash

SCRIPT_PATH = os.path.join(
os.path.dirname(__file__),
os.pardir,
Expand Down Expand Up @@ -73,6 +75,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self):


@requires_git
@requires_bash
class TestCursorFrontmatterIntegration:
"""Integration tests using a real git repo."""

Expand Down
10 changes: 8 additions & 2 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest
import json
import platform
import tempfile
import shutil
import tomllib
Expand Down Expand Up @@ -1452,6 +1453,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
ps: ../../scripts/powershell/setup-plan.ps1 -Json
agent_scripts:
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__
---

Run {SCRIPT}
Expand All @@ -1473,8 +1475,12 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
content = skill_file.read_text()
assert "{SCRIPT}" not in content
assert "{AGENT_SCRIPT}" not in content
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
if platform.system().lower().startswith("win"):
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content
else:
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
assert ".specify/scripts/bash/update-agent-context.sh codex" in content

def test_codex_skill_registration_handles_non_dict_init_options(
self, project_dir, temp_dir
Expand Down
25 changes: 22 additions & 3 deletions tests/test_timestamp_branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import pytest

from tests.conftest import requires_bash

PROJECT_ROOT = Path(__file__).resolve().parent.parent
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1"
Expand Down Expand Up @@ -149,6 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl
# ── Timestamp Branch Tests ───────────────────────────────────────────────────


@requires_bash
class TestTimestampBranch:
def test_timestamp_creates_branch(self, git_repo: Path):
"""Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix."""
Expand Down Expand Up @@ -194,6 +197,7 @@ def test_long_name_truncation(self, git_repo: Path):
# ── Sequential Branch Tests ──────────────────────────────────────────────────


@requires_bash
class TestSequentialBranch:
def test_sequential_default_with_existing_specs(self, git_repo: Path):
"""Test 2: Sequential default with existing specs."""
Expand Down Expand Up @@ -232,6 +236,8 @@ def test_sequential_supports_four_digit_prefixes(self, git_repo: Path):
branch = line.split(":", 1)[1].strip()
assert branch == "1001-next-feat", f"expected 1001-next-feat, got: {branch}"


class TestSequentialBranchPowerShell:
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
"""PowerShell scanner should parse large prefixes without [int] casts."""
content = CREATE_FEATURE_PS.read_text(encoding="utf-8")
Expand All @@ -242,6 +248,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
# ── check_feature_branch Tests ───────────────────────────────────────────────


@requires_bash
class TestCheckFeatureBranch:
def test_accepts_timestamp_branch(self):
"""Test 6: check_feature_branch accepts timestamp branch."""
Expand Down Expand Up @@ -306,6 +313,7 @@ def test_rejects_malformed_timestamp_with_prefix(self):
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────


@requires_bash
class TestFindFeatureDirByPrefix:
def test_timestamp_branch(self, tmp_path: Path):
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
Expand Down Expand Up @@ -356,6 +364,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):


class TestGetFeaturePathsSinglePrefix:
@requires_bash
def test_bash_specify_feature_prefixed_resolves_by_prefix(self, tmp_path: Path):
"""get_feature_paths: SPECIFY_FEATURE with one optional prefix uses effective name for lookup."""
(tmp_path / ".specify").mkdir()
Expand Down Expand Up @@ -399,6 +408,7 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path):
# ── get_current_branch Tests ─────────────────────────────────────────────────


@requires_bash
class TestGetCurrentBranch:
def test_env_var(self):
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
Expand All @@ -409,6 +419,7 @@ def test_env_var(self):
# ── No-git Tests ─────────────────────────────────────────────────────────────


@requires_bash
class TestNoGitTimestamp:
def test_no_git_timestamp(self, no_git_dir: Path):
"""Test 13: No-git repo + timestamp creates spec dir with warning."""
Expand All @@ -422,6 +433,7 @@ def test_no_git_timestamp(self, no_git_dir: Path):
# ── E2E Flow Tests ───────────────────────────────────────────────────────────


@requires_bash
class TestE2EFlow:
def test_e2e_timestamp(self, git_repo: Path):
"""Test 14: E2E timestamp flow — branch, dir, validation."""
Expand Down Expand Up @@ -455,6 +467,7 @@ def test_e2e_sequential(self, git_repo: Path):
# ── Allow Existing Branch Tests ──────────────────────────────────────────────


@requires_bash
class TestAllowExistingBranch:
def test_allow_existing_switches_to_branch(self, git_repo: Path):
"""T006: Pre-create branch, verify script switches to it."""
Expand Down Expand Up @@ -655,6 +668,7 @@ def test_powershell_extension_surfaces_checkout_errors(self):
# ── Dry-Run Tests ────────────────────────────────────────────────────────────


@requires_bash
class TestDryRun:
def test_dry_run_sequential_outputs_name(self, git_repo: Path):
"""T009: Dry-run computes correct branch name with existing specs."""
Expand Down Expand Up @@ -984,6 +998,7 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path):
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────


@requires_bash
class TestGitBranchNameOverrideBash:
"""Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh."""

Expand Down Expand Up @@ -1088,6 +1103,7 @@ def test_overlong_name_rejected(self, ext_ps_git_repo: Path):
class TestFeatureDirectoryResolution:
"""Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution."""

@requires_bash
def test_env_var_overrides_branch_lookup(self, git_repo: Path):
"""SPECIFY_FEATURE_DIRECTORY env var takes priority over branch-based lookup."""
custom_dir = git_repo / "my-custom-specs" / "my-feature"
Expand All @@ -1110,14 +1126,15 @@ def test_env_var_overrides_branch_lookup(self, git_repo: Path):
else:
pytest.fail("FEATURE_DIR not found in output")

@requires_bash
def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
"""feature.json feature_directory takes priority over branch-based lookup."""
custom_dir = git_repo / "specs" / "custom-feature"
custom_dir.mkdir(parents=True)

feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{custom_dir}"}}\n',
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
encoding="utf-8",
)

Expand All @@ -1136,6 +1153,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path):
else:
pytest.fail("FEATURE_DIR not found in output")

@requires_bash
def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
"""Env var wins over feature.json."""
env_dir = git_repo / "specs" / "env-feature"
Expand All @@ -1145,7 +1163,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):

feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{json_dir}"}}\n',
json.dumps({"feature_directory": str(json_dir)}) + "\n",
encoding="utf-8",
)

Expand All @@ -1165,6 +1183,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path):
else:
pytest.fail("FEATURE_DIR not found in output")

@requires_bash
def test_fallback_to_branch_lookup(self, git_repo: Path):
"""Without env var or feature.json, falls back to branch-based lookup."""
subprocess.run(["git", "checkout", "-q", "-b", "001-test-feat"], cwd=git_repo, check=True)
Expand Down Expand Up @@ -1219,7 +1238,7 @@ def test_ps_feature_json_overrides_branch_lookup(self, git_repo: Path):

feature_json = git_repo / ".specify" / "feature.json"
feature_json.write_text(
f'{{"feature_directory": "{custom_dir}"}}\n',
json.dumps({"feature_directory": str(custom_dir)}) + "\n",
encoding="utf-8",
Comment thread
mnriem marked this conversation as resolved.
)

Expand Down
Loading