ci: add windows-latest to test matrix (#2233)

* 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.
This commit is contained in:
Manfred Riem
2026-04-15 15:37:27 -05:00
committed by GitHub
parent 27b4fd2e32
commit 9988a46d96
7 changed files with 106 additions and 7 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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"

View File

@@ -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."""

View File

@@ -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

View File

@@ -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",
)