From 9988a46d96f7b3fe5e006327fb70d4de74eed217 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:37:27 -0500 Subject: [PATCH] ci: add windows-latest to test matrix (#2233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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. * 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 * 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 * 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). * test: reject WSL bash, accept only MSYS/MINGW on Windows 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. * ci: add comment explaining Windows bash test behavior * 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 * test: use bare 'bash' for detection to match test invocation 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. --- .github/workflows/test.yml | 7 ++- tests/conftest.py | 58 ++++++++++++++++++++++ tests/extensions/git/test_git_extension.py | 6 +++ tests/integrations/test_manifest.py | 4 +- tests/test_cursor_frontmatter.py | 3 ++ tests/test_extensions.py | 10 +++- tests/test_timestamp_branches.py | 25 ++++++++-- 7 files changed, 106 insertions(+), 7 deletions(-) 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", )