mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user