From e06894d18f06aa9e649c792f434116995f815b5a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:22:56 -0500 Subject: [PATCH 1/9] ci: add windows-latest to test matrix Add windows-latest to the pytest job OS matrix so tests run on both Ubuntu and Windows for all Python versions. Closes #2232 --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18b039f02b..7b83eee59a 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 From c27cc0e4e1f909fe6c873bd99565f99e5860578d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:33:03 -0500 Subject: [PATCH 2/9] test: skip bash-specific tests on Windows Add sys.platform skip markers to all test classes and methods that execute bash scripts via subprocess, so they are skipped on Windows where bash is not available. Mixed classes with both bash and pwsh tests have markers on individual bash methods only. --- tests/extensions/git/test_git_extension.py | 5 +++++ tests/test_cursor_frontmatter.py | 2 ++ tests/test_timestamp_branches.py | 16 ++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 50ab9c7b6b..76d49217b1 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -14,6 +14,7 @@ import re import shutil import subprocess +import sys from pathlib import Path import pytest @@ -211,6 +212,7 @@ def test_bundled_extension_locator(self): # ── initialize-repo.sh Tests ───────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestInitializeRepoBash: def test_initializes_git_repo(self, tmp_path: Path): """initialize-repo.sh creates a git repo with initial commit.""" @@ -269,6 +271,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path): # ── create-new-feature.sh Tests ────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestCreateFeatureBash: def test_creates_branch_sequential(self, tmp_path: Path): """Extension create-new-feature.sh creates sequential branch.""" @@ -376,6 +379,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): # ── auto-commit.sh Tests ───────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestAutoCommitBash: def test_disabled_by_default(self, tmp_path: Path): """auto-commit.sh exits silently when config is all false.""" @@ -527,6 +531,7 @@ def test_enabled_per_command(self, tmp_path: Path): # ── git-common.sh Tests ────────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestGitCommonBash: def test_has_git_true(self, tmp_path: Path): """has_git returns 0 in a git repo.""" diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py index d9d0e34237..aef8ca5b96 100644 --- a/tests/test_cursor_frontmatter.py +++ b/tests/test_cursor_frontmatter.py @@ -8,6 +8,7 @@ import os import shutil import subprocess +import sys import textwrap import pytest @@ -73,6 +74,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self): @requires_git +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestCursorFrontmatterIntegration: """Integration tests using a real git repo.""" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index b258fa98d1..36fa19dad3 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -9,6 +9,7 @@ import re import shutil import subprocess +import sys from pathlib import Path import pytest @@ -149,6 +150,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl # ── Timestamp Branch Tests ─────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestTimestampBranch: def test_timestamp_creates_branch(self, git_repo: Path): """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" @@ -194,6 +196,7 @@ def test_long_name_truncation(self, git_repo: Path): # ── Sequential Branch Tests ────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestSequentialBranch: def test_sequential_default_with_existing_specs(self, git_repo: Path): """Test 2: Sequential default with existing specs.""" @@ -242,6 +245,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): # ── check_feature_branch Tests ─────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -306,6 +310,7 @@ def test_rejects_malformed_timestamp_with_prefix(self): # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestFindFeatureDirByPrefix: def test_timestamp_branch(self, tmp_path: Path): """Test 10: find_feature_dir_by_prefix with timestamp branch.""" @@ -356,6 +361,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): class TestGetFeaturePathsSinglePrefix: + @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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 +405,7 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): # ── get_current_branch Tests ───────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -409,6 +416,7 @@ def test_env_var(self): # ── No-git Tests ───────────────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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 +430,7 @@ def test_no_git_timestamp(self, no_git_dir: Path): # ── E2E Flow Tests ─────────────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -455,6 +464,7 @@ def test_e2e_sequential(self, git_repo: Path): # ── Allow Existing Branch Tests ────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestAllowExistingBranch: def test_allow_existing_switches_to_branch(self, git_repo: Path): """T006: Pre-create branch, verify script switches to it.""" @@ -655,6 +665,7 @@ def test_powershell_extension_surfaces_checkout_errors(self): # ── Dry-Run Tests ──────────────────────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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 +995,7 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── +@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") class TestGitBranchNameOverrideBash: """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" @@ -1088,6 +1100,7 @@ def test_overlong_name_rejected(self, ext_ps_git_repo: Path): class TestFeatureDirectoryResolution: """Tests for SPECIFY_FEATURE_DIRECTORY and .specify/feature.json resolution.""" + @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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 +1123,7 @@ def test_env_var_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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" @@ -1136,6 +1150,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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" @@ -1165,6 +1180,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") + @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") 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) From de8260be39fd87421f638cac778f515259c23bf5 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:42:21 -0500 Subject: [PATCH 3/9] test: fix 3 Windows-specific test failures - test_manifest: use platform-appropriate absolute path (C:\ on Windows vs /tmp on POSIX) since /tmp is not absolute on Windows - test_extensions: add agent_scripts.ps entry and platform-conditional assertions for codex skill fallback variant test - test_timestamp_branches: use json.dumps() instead of f-string to properly escape Windows backslash paths in feature.json --- tests/integrations/test_manifest.py | 4 +++- tests/test_extensions.py | 10 ++++++++-- tests/test_timestamp_branches.py | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/integrations/test_manifest.py b/tests/integrations/test_manifest.py index b5d5bc39f5..596397d4f7 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 @@ 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" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bec939702f..460404d597 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -11,6 +11,7 @@ import pytest import json +import platform import tempfile import shutil import tomllib @@ -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} @@ -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 diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 36fa19dad3..31885af832 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -1235,7 +1235,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", ) From daf698639a51adcf737c6dcc2ca58a04fc072bab Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:51:24 -0500 Subject: [PATCH 4/9] test: extract requires_bash marker and fix PS test skip Address PR review feedback: - Define a reusable requires_bash marker in conftest.py and use it across all 3 test files instead of repeating the skipif inline - Move test_powershell_scanner_uses_long_tryparse_for_large_prefixes into its own TestSequentialBranchPowerShell class so it is not incorrectly skipped on Windows by the class-level bash marker --- tests/conftest.py | 7 +++++ tests/extensions/git/test_git_extension.py | 11 +++---- tests/test_cursor_frontmatter.py | 5 ++-- tests/test_timestamp_branches.py | 35 ++++++++++++---------- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4387c9ac8f..2cb18b3ea7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,16 @@ """Shared test helpers for the Spec Kit test suite.""" import re +import sys + +import pytest _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +requires_bash = pytest.mark.skipif( + sys.platform == "win32", reason="bash not available on Windows" +) + def strip_ansi(text: str) -> str: """Remove ANSI escape codes from Rich-formatted CLI output.""" diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py index 76d49217b1..855823d2a8 100644 --- a/tests/extensions/git/test_git_extension.py +++ b/tests/extensions/git/test_git_extension.py @@ -14,11 +14,12 @@ import re 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.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "git" EXT_BASH = EXT_DIR / "scripts" / "bash" @@ -212,7 +213,7 @@ def test_bundled_extension_locator(self): # ── initialize-repo.sh Tests ───────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestInitializeRepoBash: def test_initializes_git_repo(self, tmp_path: Path): """initialize-repo.sh creates a git repo with initial commit.""" @@ -271,7 +272,7 @@ def test_skips_if_already_git_repo(self, tmp_path: Path): # ── create-new-feature.sh Tests ────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestCreateFeatureBash: def test_creates_branch_sequential(self, tmp_path: Path): """Extension create-new-feature.sh creates sequential branch.""" @@ -379,7 +380,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path): # ── auto-commit.sh Tests ───────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestAutoCommitBash: def test_disabled_by_default(self, tmp_path: Path): """auto-commit.sh exits silently when config is all false.""" @@ -531,7 +532,7 @@ def test_enabled_per_command(self, tmp_path: Path): # ── git-common.sh Tests ────────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@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/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py index aef8ca5b96..9f8c31ce10 100644 --- a/tests/test_cursor_frontmatter.py +++ b/tests/test_cursor_frontmatter.py @@ -8,11 +8,12 @@ import os import shutil import subprocess -import sys import textwrap import pytest +from tests.conftest import requires_bash + SCRIPT_PATH = os.path.join( os.path.dirname(__file__), os.pardir, @@ -74,7 +75,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self): @requires_git -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestCursorFrontmatterIntegration: """Integration tests using a real git repo.""" diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 31885af832..7d720b47fb 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -9,11 +9,12 @@ import re 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 CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" CREATE_FEATURE_PS = PROJECT_ROOT / "scripts" / "powershell" / "create-new-feature.ps1" @@ -150,7 +151,7 @@ def source_and_call(func_call: str, env: dict | None = None) -> subprocess.Compl # ── Timestamp Branch Tests ─────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestTimestampBranch: def test_timestamp_creates_branch(self, git_repo: Path): """Test 1: --timestamp creates branch with YYYYMMDD-HHMMSS prefix.""" @@ -196,7 +197,7 @@ def test_long_name_truncation(self, git_repo: Path): # ── Sequential Branch Tests ────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestSequentialBranch: def test_sequential_default_with_existing_specs(self, git_repo: Path): """Test 2: Sequential default with existing specs.""" @@ -235,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") @@ -245,7 +248,7 @@ def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self): # ── check_feature_branch Tests ─────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestCheckFeatureBranch: def test_accepts_timestamp_branch(self): """Test 6: check_feature_branch accepts timestamp branch.""" @@ -310,7 +313,7 @@ def test_rejects_malformed_timestamp_with_prefix(self): # ── find_feature_dir_by_prefix Tests ───────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestFindFeatureDirByPrefix: def test_timestamp_branch(self, tmp_path: Path): """Test 10: find_feature_dir_by_prefix with timestamp branch.""" @@ -361,7 +364,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path): class TestGetFeaturePathsSinglePrefix: - @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") + @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() @@ -405,7 +408,7 @@ def test_ps_specify_feature_prefixed_resolves_by_prefix(self, git_repo: Path): # ── get_current_branch Tests ───────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestGetCurrentBranch: def test_env_var(self): """Test 12: get_current_branch returns SPECIFY_FEATURE env var.""" @@ -416,7 +419,7 @@ def test_env_var(self): # ── No-git Tests ───────────────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@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.""" @@ -430,7 +433,7 @@ def test_no_git_timestamp(self, no_git_dir: Path): # ── E2E Flow Tests ─────────────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestE2EFlow: def test_e2e_timestamp(self, git_repo: Path): """Test 14: E2E timestamp flow — branch, dir, validation.""" @@ -464,7 +467,7 @@ def test_e2e_sequential(self, git_repo: Path): # ── Allow Existing Branch Tests ────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestAllowExistingBranch: def test_allow_existing_switches_to_branch(self, git_repo: Path): """T006: Pre-create branch, verify script switches to it.""" @@ -665,7 +668,7 @@ def test_powershell_extension_surfaces_checkout_errors(self): # ── Dry-Run Tests ──────────────────────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@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.""" @@ -995,7 +998,7 @@ def test_ps_dry_run_json_absent_without_flag(self, ps_git_repo: Path): # ── GIT_BRANCH_NAME Override Tests ────────────────────────────────────────── -@pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") +@requires_bash class TestGitBranchNameOverrideBash: """Tests for GIT_BRANCH_NAME env var override in extension create-new-feature.sh.""" @@ -1100,7 +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.""" - @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") + @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" @@ -1123,7 +1126,7 @@ def test_env_var_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") - @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") + @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" @@ -1150,7 +1153,7 @@ def test_feature_json_overrides_branch_lookup(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") - @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") + @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" @@ -1180,7 +1183,7 @@ def test_env_var_takes_priority_over_feature_json(self, git_repo: Path): else: pytest.fail("FEATURE_DIR not found in output") - @pytest.mark.skipif(sys.platform == "win32", reason="bash not available on Windows") + @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) From 25b6affb0d1a6b46d693e6db3f391657e509f665 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:07:17 -0500 Subject: [PATCH 5/9] test: use runtime bash check instead of platform check Replace sys.platform == 'win32' with an actual bash invocation test to handle environments where bash exists but is non-functional (e.g., WSL stub on Windows without an installed distro). --- tests/conftest.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2cb18b3ea7..b7bf14bec0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,30 @@ """Shared test helpers for the Spec Kit test suite.""" import re -import sys +import shutil +import subprocess import pytest _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + +def _has_working_bash() -> bool: + """Check whether a functional bash is available (not just a WSL stub).""" + if shutil.which("bash") is None: + return False + try: + r = subprocess.run( + ["bash", "-c", "echo ok"], + capture_output=True, text=True, timeout=5, + ) + return r.returncode == 0 and "ok" in r.stdout + except (OSError, subprocess.TimeoutExpired): + return False + + requires_bash = pytest.mark.skipif( - sys.platform == "win32", reason="bash not available on Windows" + not _has_working_bash(), reason="working bash not available" ) From 5df27f1d5e4003997e42a545c297f4d9e829bbae Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:22:29 -0500 Subject: [PATCH 6/9] test: reject WSL bash, accept only MSYS/MINGW on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, verify uname -s reports MSYS, MINGW, or CYGWIN so the WSL launcher (System32\bash.exe) is rejected — it cannot handle native Windows paths used by test fixtures. Add SPECKIT_TEST_BASH=1 env var escape hatch to force-enable bash tests in non-standard setups. --- tests/conftest.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b7bf14bec0..ea5a4a2e08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ """Shared test helpers for the Spec Kit test suite.""" +import os import re import shutil import subprocess +import sys import pytest @@ -10,7 +12,17 @@ def _has_working_bash() -> bool: - """Check whether a functional bash is available (not just a WSL stub).""" + """Check whether a functional native bash is available. + + On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted. + The WSL launcher (System32\\bash.exe) 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 try: @@ -18,9 +30,24 @@ def _has_working_bash() -> bool: ["bash", "-c", "echo ok"], capture_output=True, text=True, timeout=5, ) - return r.returncode == 0 and "ok" in r.stdout + 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( From db8134da4388e17dd5c0c7bd55e84b9df37f4609 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:23:27 -0500 Subject: [PATCH 7/9] ci: add comment explaining Windows bash test behavior --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b83eee59a..44b0269887 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,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 From ca7045426861284c510da9ebc0d8b6871ce1ff0b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:29:31 -0500 Subject: [PATCH 8/9] test: early-reject WSL launcher, fix remaining f-string JSON - Check resolved bash path for System32 before spawning any subprocess to avoid WSL init prompts and timeout during test collection - Convert remaining feature_json f-string writes to json.dumps() so paths with backslashes produce valid JSON on Windows --- tests/conftest.py | 29 ++++++++++++++++------------- tests/test_timestamp_branches.py | 4 ++-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index ea5a4a2e08..8d747a8893 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,23 +23,17 @@ def _has_working_bash() -> bool: """ if os.environ.get("SPECKIT_TEST_BASH") == "1": return True - if shutil.which("bash") is None: + bash_path = shutil.which("bash") + if bash_path is None: return False - 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. + # On Windows, reject the WSL launcher early (avoids WSL init prompts + # and the 5 s timeout) and only accept MSYS/MINGW/CYGWIN bash. if sys.platform == "win32": + if "system32" in bash_path.lower(): + return False try: u = subprocess.run( - ["bash", "-c", "uname -s"], + [bash_path, "-c", "uname -s"], capture_output=True, text=True, timeout=5, ) kernel = u.stdout.strip().upper() @@ -47,6 +41,15 @@ def _has_working_bash() -> bool: return False except (OSError, subprocess.TimeoutExpired): return False + try: + r = subprocess.run( + [bash_path, "-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 return True diff --git a/tests/test_timestamp_branches.py b/tests/test_timestamp_branches.py index 7d720b47fb..39228d9455 100644 --- a/tests/test_timestamp_branches.py +++ b/tests/test_timestamp_branches.py @@ -1134,7 +1134,7 @@ def test_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", ) @@ -1163,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", ) From f71ce48231df38381e328e0530003d24fa8c4e8d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:34:57 -0500 Subject: [PATCH 9/9] test: use bare 'bash' for detection to match test invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, subprocess.run(['bash', ...]) uses CreateProcess which searches System32 before PATH — finding WSL bash even when shutil.which('bash') returns Git-for-Windows. Probe with bare 'bash' (same as test helpers) so the detection matches actual test behavior. --- tests/conftest.py | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8d747a8893..9e8ffaae59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,26 +14,40 @@ 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 (System32\\bash.exe) is rejected because it runs in - a separate Linux filesystem and cannot handle native Windows paths - used by the test fixtures. + 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 - bash_path = shutil.which("bash") - if bash_path is None: + if shutil.which("bash") is None: return False - # On Windows, reject the WSL launcher early (avoids WSL init prompts - # and the 5 s timeout) and only accept MSYS/MINGW/CYGWIN bash. - if sys.platform == "win32": - if "system32" in bash_path.lower(): + # 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_path, "-c", "uname -s"], + ["bash", "-c", "uname -s"], capture_output=True, text=True, timeout=5, ) kernel = u.stdout.strip().upper() @@ -41,15 +55,6 @@ def _has_working_bash() -> bool: return False except (OSError, subprocess.TimeoutExpired): return False - try: - r = subprocess.run( - [bash_path, "-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 return True