Files
github-spec-kit/tests/test_setup_tasks.py
Ali jawwad a4972da717 fix(scripts): count subdirectory-only dirs as non-empty in PowerShell (parity with bash) (#3137)
* fix(scripts): count subdirectory-only dirs as non-empty in PowerShell

Test-DirHasFiles (the documented PowerShell twin of bash check_dir) tested
non-emptiness with `Get-ChildItem | Where-Object { -not $_.PSIsContainer }`,
counting only top-level FILES and ignoring subdirectories. Bash check_dir
(`-n $(ls -A ...)`) and the PowerShell JSON-path contracts checks
(check-prerequisites.ps1 / setup-tasks.ps1, no PSIsContainer filter) both
count ANY entry. So a contracts/ directory whose only contents are
subdirectories (e.g. contracts/v1/openapi.yaml) was reported present by
bash, by bash JSON, and by PowerShell JSON, but [FAIL]/absent by PowerShell
text mode — the lone outlier.

Drop the PSIsContainer filter so Test-DirHasFiles counts any entry, matching
the other three code paths.

Add bash + PowerShell parity tests asserting a subdir-only contracts/ dir is
reported non-empty in both shells.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: accurate non-empty comment + drop doubled test prefix

Address review feedback on Test-DirHasFiles parity fix:

- Reword the common.ps1 comment so it no longer claims exact `ls -A` parity (Get-ChildItem omits hidden entries without -Force); it now points at the in-repo PowerShell JSON contracts checks as the matching reference and keeps the subdir-only-is-non-empty rationale.

- Rename test_test_dir_has_files_ps_... -> test_dir_has_files_ps_... to drop the doubled 'test_' prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: assert dir-non-emptiness via stdout marker, not exit code

Address Copilot review: check_dir always exits 0 (it echoes the marker rather than setting an exit code) and Test-DirHasFiles returns a boolean (pwsh still exits 0 when it returns $false), so 'result.returncode == 0' validated nothing. Drop the misleading assertion and rely on the [OK]/checkmark marker in stdout, which is the actual behavioral signal; document why inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep common.ps1 ASCII-only (PowerShell 5.1 compatibility)

My reworded Test-DirHasFiles comment introduced an em dash (U+2014), which tripped tests/test_ps1_encoding.py::test_ps1_file_is_ascii_only -- .ps1 files must stay ASCII for Windows PowerShell 5.1. Replace it with '--', matching the existing comment style in this file (e.g. the Resolve-SpecifyInitDir docstring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: decode dir-parity subprocess output as UTF-8 explicitly

Address Copilot review: check_dir echoes the non-ASCII markers ✓/✗, and subprocess.run with text=True but no encoding decodes via the platform locale (cp1252 on Windows), which can raise UnicodeDecodeError or mangle stdout. Pin encoding='utf-8' on both the bash and PowerShell dir-parity helpers so decoding is deterministic across CI runners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:38:39 -05:00

894 lines
32 KiB
Python

"""Tests for setup-tasks.{sh,ps1} template resolution and feature resolution."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
CHECK_PREREQ_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
CHECK_PREREQ_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
shutil.copy(CHECK_PREREQ_SH, d / "check-prerequisites.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
shutil.copy(CHECK_PREREQ_PS, d / "check-prerequisites.ps1")
def _install_core_tasks_template(repo: Path) -> None:
"""Copy the real tasks-template.md into the core template location."""
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _write_feature_json(
repo: Path, feature_directory: str = "specs/001-my-feature"
) -> None:
(repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": feature_directory}),
encoding="utf-8",
)
def _minimal_feature(repo: Path) -> Path:
"""
Create a numbered branch-style feature directory with spec.md and plan.md
so all prerequisite checks in setup-tasks pass.
Returns the feature directory path.
"""
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(repo)
return feat
def _write_integration_state(repo: Path, integration: str = "claude", separator: str = "-") -> None:
specify_dir = repo / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
state = {
"integration": integration,
"default_integration": integration,
"installed_integrations": [integration],
"integration_settings": {
integration: {
"invoke_separator": separator,
},
},
}
(specify_dir / "integration.json").write_text(
json.dumps(state),
encoding="utf-8",
)
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on feature.json and on-disk feature directories set up by each fixture.
"""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _run_bash_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
["bash", "-c", 'source "$1"; format_speckit_command "$2" "$PWD"', "bash", str(script), command_name],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
def _run_powershell_format_command(repo: Path, command_name: str) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return subprocess.run(
[
exe,
"-NoProfile",
"-Command",
'& { param($common, $commandName) . $common; Format-SpecKitCommand -CommandName $commandName -RepoRoot (Get-Location).Path }',
str(script),
command_name,
],
cwd=repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def tasks_repo(tmp_path: Path) -> Path:
"""
A minimal repo with:
- git initialised on a numbered branch (001-my-feature)
- core tasks-template.md in place
- both bash and PowerShell scripts installed
"""
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
# Keep a numbered branch name in this repo fixture; setup-tasks now resolves
# feature directories from repository state rather than validating git branches.
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo,
check=True,
)
(repo / ".specify").mkdir()
_install_core_tasks_template(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ===========================================================================
# BASH TESTS
# ===========================================================================
@requires_bash
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
When the core tasks-template.md is present and all prerequisites are met,
setup-tasks.sh --json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path pointing to the core template.
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@requires_bash
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
"""
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.sh --json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
# Create the override
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
# The resolved path must be inside the overrides directory
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
"""
When an extension template exists, setup-tasks.sh --json must resolve
tasks-template.md from the extension before falling back to the core path.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
)
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == extension_file.resolve(), (
f"Expected extension path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
"""
When both preset and extension templates exist, setup-tasks.sh --json must
resolve the preset path because presets outrank extensions.
"""
_minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
)
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
preset_dir.mkdir(parents=True, exist_ok=True)
preset_file = preset_dir / "tasks-template.md"
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == preset_file.resolve(), (
f"Expected preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
"""
When two presets both provide tasks-template.md, the one listed first in
.specify/presets/.registry wins.
"""
_minimal_feature(tasks_repo)
# resolve_template reads .specify/presets/.registry as a JSON object with a
# "presets" map where each entry has a numeric "priority" (lower = higher
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
high_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates"
)
high_priority_dir.mkdir(parents=True, exist_ok=True)
high_priority_file = high_priority_dir / "tasks-template.md"
high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8")
low_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
)
low_priority_dir.mkdir(parents=True, exist_ok=True)
low_priority_file = low_priority_dir / "tasks-template.md"
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
# Write .registry JSON using the correct schema: object with "presets" map,
# each preset has a numeric "priority" (lower number = higher precedence).
registry_json = tasks_repo / ".specify" / "presets" / ".registry"
registry_json.write_text(
json.dumps({
"presets": {
"priority-1-preset": {"priority": 1, "enabled": True},
"priority-2-preset": {"priority": 2, "enabled": True},
}
}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == high_priority_file.resolve(), (
f"Expected high-priority preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
"""
When tasks-template.md is absent from all locations, setup-tasks.sh must
exit non-zero and print a helpful ERROR message to stderr.
"""
_minimal_feature(tasks_repo)
# Remove the core template so no template exists anywhere
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "ERROR" in result.stderr
assert "tasks-template" in result.stderr
@requires_bash
def test_bash_command_hint_defaults_to_dot_without_integration_json(tasks_repo: Path) -> None:
integration_json = tasks_repo / ".specify" / "integration.json"
if integration_json.exists():
integration_json.unlink()
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_rejects_invalid_invoke_separator(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "/")
result = _run_bash_format_command(tasks_repo, "plan")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.plan"
@requires_bash
def test_bash_command_hint_normalizes_mixed_separators(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_bash_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@requires_bash
def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_bash_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@requires_bash
def test_bash_command_hint_caches_invoke_separator_per_process(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
script = tasks_repo / ".specify" / "scripts" / "bash" / "common.sh"
dot_state = {
"integration": "copilot",
"default_integration": "copilot",
"installed_integrations": ["copilot"],
"integration_settings": {"copilot": {"invoke_separator": "."}},
}
result = subprocess.run(
[
"bash",
"-c",
'source "$1"; format_speckit_command plan "$PWD"; printf "%s" "$2" > .specify/integration.json; format_speckit_command tasks "$PWD"',
"bash",
str(script),
json.dumps(dot_state),
],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == ["/speckit-plan", "/speckit-tasks"]
@requires_bash
def test_setup_tasks_bash_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Run /speckit-plan first" in result.stderr
assert "/speckit.plan" not in result.stderr
@requires_bash
def test_check_prerequisites_bash_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--require-tasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Run /speckit-tasks first" in result.stderr
assert "/speckit.tasks" not in result.stderr
@requires_bash
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch, setup-tasks.sh must succeed when feature.json
pins a valid FEATURE_DIR (branch validation should be skipped).
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_setup_tasks_bash_errors_without_feature_context(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.sh must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
When the core tasks-template.md is present and all prerequisites are met,
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path.
"""
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
"""
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.ps1 -Json must return the override path, not the core path.
"""
_minimal_feature(tasks_repo)
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
"""
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
exit non-zero and write a helpful error to stderr.
"""
_minimal_feature(tasks_repo)
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_normalizes_mixed_separators(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "/speckit-git.commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.git.commit"
_write_integration_state(tasks_repo, "claude", "-")
result = _run_powershell_format_command(tasks_repo, "speckit.git-commit")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit-git-commit"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_powershell_command_hint_preserves_hyphens_inside_segments(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "copilot", ".")
result = _run_powershell_format_command(tasks_repo, "speckit.jira.sync-status")
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "/speckit.jira.sync-status"
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_uses_invoke_separator_in_plan_hint(tasks_repo: Path) -> None:
_write_integration_state(tasks_repo, "claude", "-")
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-plan first" in output
assert "/speckit.plan" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_check_prerequisites_ps_uses_invoke_separator_in_tasks_hint(
tasks_repo: Path,
) -> None:
_write_integration_state(tasks_repo, "claude", "-")
_minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-RequireTasks"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Run /speckit-tasks first" in output
assert "/speckit.tasks" not in output
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch, setup-tasks.ps1 must succeed when feature.json
pins a valid FEATURE_DIR (branch validation should be skipped).
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_errors_without_feature_context(
tasks_repo: Path,
) -> None:
"""Without feature.json or SPECIFY_FEATURE_DIRECTORY, setup-tasks.ps1 must error."""
main_feat = tasks_repo / "specs" / "main"
main_feat.mkdir(parents=True, exist_ok=True)
(main_feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(main_feat / "plan.md").write_text("# plan\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
output = result.stderr + result.stdout
assert result.returncode != 0
assert "Feature directory not found" in output
# ---------------------------------------------------------------------------
# Directory non-emptiness parity: a dir whose only contents are subdirectories
# (e.g. contracts/v1/openapi.yaml) must count as non-empty in both shells.
# ---------------------------------------------------------------------------
def _run_bash_check_dir(repo: Path, target: Path) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "bash" / "common.sh"
return subprocess.run(
["bash", "-c", 'source "$1"; check_dir "$2" "contracts/"', "bash", str(script), str(target)],
# check_dir echoes the non-ASCII markers ✓/✗; decode UTF-8 explicitly so
# the result does not depend on the platform locale (e.g. cp1252 on Windows).
cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(),
)
def _run_powershell_test_dir(repo: Path, target: Path) -> subprocess.CompletedProcess:
script = repo / ".specify" / "scripts" / "powershell" / "common.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
return subprocess.run(
[exe, "-NoProfile", "-Command",
'& { param($common, $dir) . $common; Test-DirHasFiles -Path $dir -Description "contracts/" }',
str(script), str(target)],
cwd=repo, capture_output=True, text=True, encoding="utf-8", check=False, env=_clean_env(),
)
@requires_bash
def test_check_dir_bash_counts_subdir_only_contracts(tasks_repo: Path) -> None:
"""bash check_dir treats a dir containing only subdirectories as non-empty."""
contracts = tasks_repo / "contracts" / "v1"
contracts.mkdir(parents=True)
(contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8")
result = _run_bash_check_dir(tasks_repo, tasks_repo / "contracts")
# check_dir always exits 0 (it echoes ✓/✗ instead of setting an exit code),
# so the ✓ marker in stdout — not the return code — is what proves non-emptiness.
assert "" in result.stdout and "" not in result.stdout, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_dir_has_files_ps_counts_subdir_only_contracts(tasks_repo: Path) -> None:
"""Test-DirHasFiles must match bash: a subdir-only dir counts as non-empty."""
contracts = tasks_repo / "contracts" / "v1"
contracts.mkdir(parents=True)
(contracts / "openapi.yaml").write_text("openapi: 3.0\n", encoding="utf-8")
result = _run_powershell_test_dir(tasks_repo, tasks_repo / "contracts")
# Test-DirHasFiles returns a boolean and pwsh still exits 0 when it returns
# $false, so the [OK] marker in stdout — not the return code — is what proves
# non-emptiness.
assert "[OK]" in result.stdout and "[FAIL]" not in result.stdout, result.stderr + result.stdout