Skip to content

Commit e072ad2

Browse files
committed
test: Add automated pytest suite for extension validation
4 test modules covering manifest, commands, structure, and config. CI workflow runs on Python 3.11/3.12/3.13 via GitHub Actions.
1 parent d6dba6a commit e072ad2

7 files changed

Lines changed: 318 additions & 0 deletions

File tree

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Tests
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
strategy:
11+
matrix:
12+
python-version: ["3.11", "3.12", "3.13"]
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-python@v6
16+
with:
17+
python-version: ${{ matrix.python-version }}
18+
- run: pip install pytest pyyaml
19+
- run: pytest

pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "spec-kit-optimize"
3+
version = "1.0.0"
4+
requires-python = ">=3.11"
5+
6+
[project.optional-dependencies]
7+
test = ["pytest>=7.0", "pyyaml>=6.0"]
8+
9+
[tool.pytest.ini_options]
10+
testpaths = ["tests"]
11+
python_files = ["test_*.py"]
12+
python_functions = ["test_*"]
13+
addopts = ["-v", "--strict-markers", "--tb=short"]

tests/conftest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Shared fixtures for spec-kit-optimize tests."""
2+
3+
import pytest
4+
import yaml
5+
from pathlib import Path
6+
7+
8+
ROOT = Path(__file__).parent.parent
9+
10+
11+
@pytest.fixture
12+
def root():
13+
"""Extension root directory."""
14+
return ROOT
15+
16+
17+
@pytest.fixture
18+
def manifest(root):
19+
"""Parsed extension.yml manifest."""
20+
with open(root / "extension.yml") as f:
21+
return yaml.safe_load(f)
22+
23+
24+
@pytest.fixture
25+
def extension(manifest):
26+
"""The extension section of the manifest."""
27+
return manifest["extension"]
28+
29+
30+
@pytest.fixture
31+
def commands(manifest):
32+
"""List of provided commands from the manifest."""
33+
return manifest["provides"]["commands"]
34+
35+
36+
@pytest.fixture
37+
def config_template(root):
38+
"""Parsed config-template.yml."""
39+
with open(root / "config-template.yml") as f:
40+
return yaml.safe_load(f)

tests/test_commands.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Tests for command file validation."""
2+
3+
import yaml
4+
5+
6+
def test_command_files_exist(root, commands):
7+
for cmd in commands:
8+
cmd_file = root / cmd["file"]
9+
assert cmd_file.exists(), f"Command file not found: {cmd['file']}"
10+
11+
12+
def test_command_files_non_empty(root, commands):
13+
for cmd in commands:
14+
cmd_file = root / cmd["file"]
15+
content = cmd_file.read_text(encoding="utf-8")
16+
assert len(content.strip()) > 0, f"Command file is empty: {cmd['file']}"
17+
18+
19+
def test_command_frontmatter_valid(root, commands):
20+
for cmd in commands:
21+
cmd_file = root / cmd["file"]
22+
content = cmd_file.read_text(encoding="utf-8")
23+
assert content.startswith("---"), (
24+
f"Command file must start with YAML frontmatter: {cmd['file']}"
25+
)
26+
end = content.index("---", 3)
27+
frontmatter = content[3:end].strip()
28+
parsed = yaml.safe_load(frontmatter)
29+
assert isinstance(parsed, dict), (
30+
f"Frontmatter must be a YAML mapping: {cmd['file']}"
31+
)
32+
33+
34+
def test_command_frontmatter_has_description(root, commands):
35+
for cmd in commands:
36+
cmd_file = root / cmd["file"]
37+
content = cmd_file.read_text(encoding="utf-8")
38+
end = content.index("---", 3)
39+
frontmatter = yaml.safe_load(content[3:end])
40+
assert "description" in frontmatter, (
41+
f"Frontmatter missing 'description': {cmd['file']}"
42+
)
43+
44+
45+
def test_command_has_markdown_body(root, commands):
46+
for cmd in commands:
47+
cmd_file = root / cmd["file"]
48+
content = cmd_file.read_text(encoding="utf-8")
49+
end = content.index("---", 3) + 3
50+
body = content[end:].strip()
51+
assert len(body) > 100, (
52+
f"Command body too short ({len(body)} chars): {cmd['file']}"
53+
)
54+
55+
56+
def test_command_has_goal_section(root, commands):
57+
for cmd in commands:
58+
cmd_file = root / cmd["file"]
59+
content = cmd_file.read_text(encoding="utf-8")
60+
assert "## Goal" in content, (
61+
f"Command file missing '## Goal' section: {cmd['file']}"
62+
)
63+
64+
65+
def test_command_has_execution_steps(root, commands):
66+
for cmd in commands:
67+
cmd_file = root / cmd["file"]
68+
content = cmd_file.read_text(encoding="utf-8")
69+
assert "## Execution Steps" in content or "## Execution" in content, (
70+
f"Command file missing execution steps: {cmd['file']}"
71+
)

tests/test_config.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Tests for config template validation."""
2+
3+
import yaml
4+
5+
6+
def test_config_template_valid_yaml(root):
7+
with open(root / "config-template.yml") as f:
8+
config = yaml.safe_load(f)
9+
assert isinstance(config, dict), "Config template must be a YAML mapping"
10+
11+
12+
def test_config_template_has_categories(config_template):
13+
assert "categories" in config_template, "Config template missing 'categories'"
14+
15+
16+
def test_config_template_has_thresholds(config_template):
17+
assert "thresholds" in config_template, "Config template missing 'thresholds'"
18+
19+
20+
def test_defaults_match_config_structure(manifest, config_template):
21+
defaults = manifest.get("defaults", {})
22+
if "categories" in defaults:
23+
for key in defaults["categories"]:
24+
assert key in config_template.get("categories", {}), (
25+
f"Default category '{key}' not in config template"
26+
)
27+
28+
29+
def test_threshold_values_positive(config_template):
30+
thresholds = config_template.get("thresholds", {})
31+
for key, value in thresholds.items():
32+
assert isinstance(value, (int, float)), (
33+
f"Threshold '{key}' must be numeric, got {type(value).__name__}"
34+
)
35+
assert value > 0, f"Threshold '{key}' must be positive, got {value}"
36+
37+
38+
def test_config_template_documented(root):
39+
content = (root / "config-template.yml").read_text(encoding="utf-8")
40+
comment_lines = [line for line in content.splitlines() if line.strip().startswith("#")]
41+
assert len(comment_lines) >= 5, (
42+
f"Config template should have documentation comments (found {len(comment_lines)})"
43+
)

tests/test_manifest.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for extension.yml manifest validation."""
2+
3+
import re
4+
5+
6+
def test_schema_version(manifest):
7+
assert manifest["schema_version"] == "1.0"
8+
9+
10+
def test_required_fields(extension):
11+
required = ["id", "name", "version", "description", "author", "repository", "license"]
12+
for field in required:
13+
assert field in extension, f"Missing required field: extension.{field}"
14+
15+
16+
def test_id_format(extension):
17+
assert re.match(r"^[a-z0-9-]+$", extension["id"]), (
18+
f"Extension ID must be lowercase with hyphens only, got: {extension['id']}"
19+
)
20+
21+
22+
def test_version_semver(extension):
23+
assert re.match(r"^\d+\.\d+\.\d+$", extension["version"]), (
24+
f"Version must follow semver (X.Y.Z), got: {extension['version']}"
25+
)
26+
27+
28+
def test_description_length(extension):
29+
assert len(extension["description"]) < 200, (
30+
f"Description too long: {len(extension['description'])} chars (max 200)"
31+
)
32+
33+
34+
def test_repository_url(extension):
35+
assert extension["repository"].startswith("https://"), (
36+
f"Repository must be an HTTPS URL, got: {extension['repository']}"
37+
)
38+
39+
40+
def test_tags_present(manifest):
41+
tags = manifest.get("tags", [])
42+
assert 2 <= len(tags) <= 10, f"Expected 2-10 tags, got {len(tags)}"
43+
44+
45+
def test_tags_lowercase(manifest):
46+
for tag in manifest.get("tags", []):
47+
assert tag == tag.lower(), f"Tag must be lowercase: {tag}"
48+
49+
50+
def test_command_naming(manifest):
51+
ext_id = manifest["extension"]["id"]
52+
for cmd in manifest["provides"]["commands"]:
53+
name = cmd["name"]
54+
assert name.startswith(f"speckit.{ext_id}."), (
55+
f"Command name must match speckit.{ext_id}.<cmd>, got: {name}"
56+
)
57+
58+
59+
def test_command_has_description(manifest):
60+
for cmd in manifest["provides"]["commands"]:
61+
assert "description" in cmd, f"Command {cmd['name']} missing description"
62+
assert len(cmd["description"]) > 0, f"Command {cmd['name']} has empty description"
63+
64+
65+
def test_command_has_file(manifest):
66+
for cmd in manifest["provides"]["commands"]:
67+
assert "file" in cmd, f"Command {cmd['name']} missing file reference"
68+
69+
70+
def test_requires_speckit_version(manifest):
71+
assert "requires" in manifest
72+
assert "speckit_version" in manifest["requires"], "Missing requires.speckit_version"

tests/test_structure.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Tests for extension file structure."""
2+
3+
from pathlib import Path
4+
5+
6+
def test_required_files_exist(root):
7+
required = ["extension.yml", "README.md", "LICENSE"]
8+
for filename in required:
9+
assert (root / filename).exists(), f"Required file missing: {filename}"
10+
11+
12+
def test_recommended_files_exist(root):
13+
recommended = ["CHANGELOG.md"]
14+
for filename in recommended:
15+
assert (root / filename).exists(), f"Recommended file missing: {filename}"
16+
17+
18+
def test_config_template_exists(root, manifest):
19+
configs = manifest.get("provides", {}).get("config", [])
20+
for cfg in configs:
21+
template = cfg.get("template")
22+
if template:
23+
assert (root / template).exists(), (
24+
f"Config template not found: {template}"
25+
)
26+
27+
28+
def test_no_stale_command_files(root, commands):
29+
declared_files = {cmd["file"] for cmd in commands}
30+
commands_dir = root / "commands"
31+
if commands_dir.exists():
32+
actual_files = {
33+
f"commands/{f.name}" for f in commands_dir.glob("*.md")
34+
}
35+
stale = actual_files - declared_files
36+
assert not stale, (
37+
f"Stale command files not declared in manifest: {stale}"
38+
)
39+
40+
41+
def test_commands_directory_exists(root):
42+
assert (root / "commands").is_dir(), "commands/ directory missing"
43+
44+
45+
def test_license_not_empty(root):
46+
content = (root / "LICENSE").read_text(encoding="utf-8")
47+
assert len(content.strip()) > 50, "LICENSE file appears empty or too short"
48+
49+
50+
def test_readme_has_installation(root):
51+
content = (root / "README.md").read_text(encoding="utf-8")
52+
assert "install" in content.lower(), "README.md missing installation instructions"
53+
54+
55+
def test_readme_has_commands(root, commands):
56+
content = (root / "README.md").read_text(encoding="utf-8")
57+
for cmd in commands:
58+
assert cmd["name"] in content, (
59+
f"README.md does not document command: {cmd['name']}"
60+
)

0 commit comments

Comments
 (0)