Skip to content

Commit 9988a46

Browse files
authored
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.
1 parent 27b4fd2 commit 9988a46

File tree

7 files changed

+106
-7
lines changed

7 files changed

+106
-7
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ jobs:
2727
run: uvx ruff check src/
2828

2929
pytest:
30-
runs-on: ubuntu-latest
30+
runs-on: ${{ matrix.os }}
3131
strategy:
3232
matrix:
33+
os: [ubuntu-latest, windows-latest]
3334
python-version: ["3.11", "3.12", "3.13"]
3435
steps:
3536
- name: Checkout
@@ -46,5 +47,9 @@ jobs:
4647
- name: Install dependencies
4748
run: uv sync --extra test
4849

50+
# On windows-latest, bash tests auto-skip unless Git-for-Windows
51+
# bash (MSYS2/MINGW) is detected. The WSL launcher is rejected
52+
# because it cannot handle native Windows paths in test fixtures.
53+
# See tests/conftest.py::_has_working_bash() for details.
4954
- name: Run tests
5055
run: uv run pytest

tests/conftest.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,68 @@
11
"""Shared test helpers for the Spec Kit test suite."""
22

3+
import os
34
import re
5+
import shutil
6+
import subprocess
7+
import sys
8+
9+
import pytest
410

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

713

14+
def _has_working_bash() -> bool:
15+
"""Check whether a functional native bash is available.
16+
17+
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
18+
which searches System32 *before* PATH — so it may find the WSL
19+
launcher even when Git-for-Windows bash appears first in PATH via
20+
``shutil.which``. We therefore probe with bare ``"bash"`` (the
21+
same way test helpers invoke it) to get an accurate result.
22+
23+
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
24+
The WSL launcher is rejected because it runs in a separate Linux
25+
filesystem and cannot handle native Windows paths used by the
26+
test fixtures.
27+
28+
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
29+
"""
30+
if os.environ.get("SPECKIT_TEST_BASH") == "1":
31+
return True
32+
if shutil.which("bash") is None:
33+
return False
34+
# Probe with bare "bash" — same as the test helpers — so that
35+
# Windows CreateProcess resolution order is respected.
36+
try:
37+
r = subprocess.run(
38+
["bash", "-c", "echo ok"],
39+
capture_output=True, text=True, timeout=5,
40+
)
41+
if r.returncode != 0 or "ok" not in r.stdout:
42+
return False
43+
except (OSError, subprocess.TimeoutExpired):
44+
return False
45+
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
46+
# not the WSL launcher which can't handle native paths.
47+
if sys.platform == "win32":
48+
try:
49+
u = subprocess.run(
50+
["bash", "-c", "uname -s"],
51+
capture_output=True, text=True, timeout=5,
52+
)
53+
kernel = u.stdout.strip().upper()
54+
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
55+
return False
56+
except (OSError, subprocess.TimeoutExpired):
57+
return False
58+
return True
59+
60+
61+
requires_bash = pytest.mark.skipif(
62+
not _has_working_bash(), reason="working bash not available"
63+
)
64+
65+
866
def strip_ansi(text: str) -> str:
967
"""Remove ANSI escape codes from Rich-formatted CLI output."""
1068
return _ANSI_ESCAPE_RE.sub("", text)

tests/extensions/git/test_git_extension.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import pytest
2020

21+
from tests.conftest import requires_bash
22+
2123
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
2224
EXT_DIR = PROJECT_ROOT / "extensions" / "git"
2325
EXT_BASH = EXT_DIR / "scripts" / "bash"
@@ -211,6 +213,7 @@ def test_bundled_extension_locator(self):
211213
# ── initialize-repo.sh Tests ─────────────────────────────────────────────────
212214

213215

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

271274

275+
@requires_bash
272276
class TestCreateFeatureBash:
273277
def test_creates_branch_sequential(self, tmp_path: Path):
274278
"""Extension create-new-feature.sh creates sequential branch."""
@@ -376,6 +380,7 @@ def test_no_git_graceful_degradation(self, tmp_path: Path):
376380
# ── auto-commit.sh Tests ─────────────────────────────────────────────────────
377381

378382

383+
@requires_bash
379384
class TestAutoCommitBash:
380385
def test_disabled_by_default(self, tmp_path: Path):
381386
"""auto-commit.sh exits silently when config is all false."""
@@ -583,6 +588,7 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
583588
# ── git-common.sh Tests ──────────────────────────────────────────────────────
584589

585590

591+
@requires_bash
586592
class TestGitCommonBash:
587593
def test_has_git_true(self, tmp_path: Path):
588594
"""has_git returns 0 in a git repo."""

tests/integrations/test_manifest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import hashlib
44
import json
5+
import sys
56

67
import pytest
78

@@ -41,8 +42,9 @@ def test_record_file_rejects_parent_traversal(self, tmp_path):
4142

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

4749
def test_record_existing_rejects_parent_traversal(self, tmp_path):
4850
escape = tmp_path.parent / "escape.txt"

tests/test_cursor_frontmatter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
import pytest
1414

15+
from tests.conftest import requires_bash
16+
1517
SCRIPT_PATH = os.path.join(
1618
os.path.dirname(__file__),
1719
os.pardir,
@@ -73,6 +75,7 @@ def test_powershell_script_has_mdc_frontmatter_logic(self):
7375

7476

7577
@requires_git
78+
@requires_bash
7679
class TestCursorFrontmatterIntegration:
7780
"""Integration tests using a real git repo."""
7881

tests/test_extensions.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pytest
1313
import json
14+
import platform
1415
import tempfile
1516
import shutil
1617
import tomllib
@@ -1452,6 +1453,7 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
14521453
ps: ../../scripts/powershell/setup-plan.ps1 -Json
14531454
agent_scripts:
14541455
sh: ../../scripts/bash/update-agent-context.sh __AGENT__
1456+
ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__
14551457
---
14561458
14571459
Run {SCRIPT}
@@ -1473,8 +1475,12 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti
14731475
content = skill_file.read_text()
14741476
assert "{SCRIPT}" not in content
14751477
assert "{AGENT_SCRIPT}" not in content
1476-
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
1477-
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
1478+
if platform.system().lower().startswith("win"):
1479+
assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content
1480+
assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content
1481+
else:
1482+
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
1483+
assert ".specify/scripts/bash/update-agent-context.sh codex" in content
14781484

14791485
def test_codex_skill_registration_handles_non_dict_init_options(
14801486
self, project_dir, temp_dir

tests/test_timestamp_branches.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import pytest
1515

16+
from tests.conftest import requires_bash
17+
1618
PROJECT_ROOT = Path(__file__).resolve().parent.parent
1719
CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh"
1820
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
149151
# ── Timestamp Branch Tests ───────────────────────────────────────────────────
150152

151153

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

196199

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

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

244250

251+
@requires_bash
245252
class TestCheckFeatureBranch:
246253
def test_accepts_timestamp_branch(self):
247254
"""Test 6: check_feature_branch accepts timestamp branch."""
@@ -306,6 +313,7 @@ def test_rejects_malformed_timestamp_with_prefix(self):
306313
# ── find_feature_dir_by_prefix Tests ─────────────────────────────────────────
307314

308315

316+
@requires_bash
309317
class TestFindFeatureDirByPrefix:
310318
def test_timestamp_branch(self, tmp_path: Path):
311319
"""Test 10: find_feature_dir_by_prefix with timestamp branch."""
@@ -356,6 +364,7 @@ def test_timestamp_with_single_path_prefix_cross_branch(self, tmp_path: Path):
356364

357365

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

401410

411+
@requires_bash
402412
class TestGetCurrentBranch:
403413
def test_env_var(self):
404414
"""Test 12: get_current_branch returns SPECIFY_FEATURE env var."""
@@ -409,6 +419,7 @@ def test_env_var(self):
409419
# ── No-git Tests ─────────────────────────────────────────────────────────────
410420

411421

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

424435

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

457469

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

657670

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

9861000

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

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

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

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

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

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

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

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

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

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

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

0 commit comments

Comments
 (0)