diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b039f02..44b026988 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 4387c9ac8..9e8ffaae5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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" +) + + def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" return _ANSI_ESCAPE_RE.sub("", text) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index a04acba10..30694fc9d 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -18,6 +18,8 @@ from pathlib import Path 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" @@ -211,6 +213,7 @@ class TestGitExtensionInstall: # ── 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.""" @@ -269,6 +272,7 @@ class TestInitializeRepoPowerShell: # ── 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.""" @@ -376,6 +380,7 @@ class TestCreateFeaturePowerShell: # ── 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.""" @@ -583,6 +588,7 @@ class TestAutoCommitPowerShell: # ── 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.""" diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index b5d5bc39f..596397d4f 100644 --- a/tests/integrations/test_manifest.py +++ b/tests/integrations/test_manifest.py @@ -2,6 +2,7 @@ import hashlib import json +import sys import pytest @@ -41,8 +42,9 @@ class TestManifestPathTraversal: 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" diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py index d9d0e3423..9f8c31ce1 100644 --- a/tests/test_cursor_frontmatter.py +++ b/tests/test_cursor_frontmatter.py @@ -12,6 +12,8 @@ import textwrap import pytest +from tests.conftest import requires_bash + SCRIPT_PATH = os.path.join( os.path.dirname(__file__), os.pardir, @@ -73,6 +75,7 @@ class TestScriptFrontmatterPattern: @requires_git +@requires_bash class TestCursorFrontmatterIntegration: """Integration tests using a real git repo.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bec939702..460404d59 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ Tests cover: import pytest import json +import platform import tempfile import shutil import tomllib @@ -1452,6 +1453,7 @@ scripts: 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} @@ -1473,8 +1475,12 @@ Then {AGENT_SCRIPT} 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 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index b258fa98d..39228d945 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -13,6 +13,8 @@ from pathlib import Path 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" @@ -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.""" @@ -194,6 +197,7 @@ class TestTimestampBranch: # ── 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.""" @@ -232,6 +236,8 @@ class TestSequentialBranch: 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") @@ -242,6 +248,7 @@ class TestSequentialBranch: # ── check_feature_branch Tests ─────────────────────────────────────────────── +@requires_bash class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -306,6 +313,7 @@ class TestCheckFeatureBranch: # ── 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.""" @@ -356,6 +364,7 @@ class TestFindFeatureDirByPrefix: 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() @@ -399,6 +408,7 @@ class TestGetFeaturePathsSinglePrefix: # ── get_current_branch Tests ───────────────────────────────────────────────── +@requires_bash class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -409,6 +419,7 @@ class TestGetCurrentBranch: # ── 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.""" @@ -422,6 +433,7 @@ class TestNoGitTimestamp: # ── E2E Flow Tests ─────────────────────────────────────────────────────────── +@requires_bash class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -455,6 +467,7 @@ class TestE2EFlow: # ── 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.""" @@ -655,6 +668,7 @@ class TestGitExtensionParity: # ── 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.""" @@ -984,6 +998,7 @@ class TestPowerShellDryRun: # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── +@requires_bash class TestGitBranchNameOverrideBash: """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" @@ -1088,6 +1103,7 @@ class TestGitBranchNameOverridePowerShell: 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" @@ -1110,6 +1126,7 @@ class TestFeatureDirectoryResolution: 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" @@ -1117,7 +1134,7 @@ class TestFeatureDirectoryResolution: 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", ) @@ -1136,6 +1153,7 @@ class TestFeatureDirectoryResolution: 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" @@ -1145,7 +1163,7 @@ class TestFeatureDirectoryResolution: 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", ) @@ -1165,6 +1183,7 @@ class TestFeatureDirectoryResolution: 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) @@ -1219,7 +1238,7 @@ class TestFeatureDirectoryResolution: 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", )