refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
230
.github/skills/bmad-module-builder/scripts/tests/test-scaffold-setup-skill.py
vendored
Normal file
230
.github/skills/bmad-module-builder/scripts/tests/test-scaffold-setup-skill.py
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for scaffold-setup-skill.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-setup-skill.py"
|
||||
TEMPLATE_DIR = Path(__file__).resolve().parent.parent.parent / "assets" / "setup-skill-template"
|
||||
|
||||
|
||||
def run_scaffold(tmp: Path, **kwargs) -> tuple[int, dict]:
|
||||
"""Run the scaffold script and return (exit_code, parsed_json)."""
|
||||
target_dir = kwargs.get("target_dir", str(tmp / "output"))
|
||||
Path(target_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
module_code = kwargs.get("module_code", "tst")
|
||||
module_name = kwargs.get("module_name", "Test Module")
|
||||
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
yaml_path.write_text(kwargs.get("yaml_content", f'code: {module_code}\nname: "{module_name}"\n'))
|
||||
csv_path.write_text(
|
||||
kwargs.get(
|
||||
"csv_content",
|
||||
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
f'{module_name},{module_code}-example,Example,EX,An example skill,do-thing,,anytime,,,false,output_folder,artifact\n',
|
||||
)
|
||||
)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", target_dir,
|
||||
"--module-code", module_code,
|
||||
"--module-name", module_name,
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, data
|
||||
|
||||
|
||||
def test_basic_scaffold():
|
||||
"""Test that scaffolding creates the expected structure."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
code, data = run_scaffold(tmp, target_dir=str(target_dir))
|
||||
assert code == 0, f"Script failed: {data}"
|
||||
assert data["status"] == "success"
|
||||
assert data["setup_skill"] == "tst-setup"
|
||||
|
||||
setup_dir = target_dir / "tst-setup"
|
||||
assert setup_dir.is_dir()
|
||||
assert (setup_dir / "SKILL.md").is_file()
|
||||
assert (setup_dir / "scripts" / "merge-config.py").is_file()
|
||||
assert (setup_dir / "scripts" / "merge-help-csv.py").is_file()
|
||||
assert (setup_dir / "scripts" / "cleanup-legacy.py").is_file()
|
||||
assert (setup_dir / "assets" / "module.yaml").is_file()
|
||||
assert (setup_dir / "assets" / "module-help.csv").is_file()
|
||||
|
||||
|
||||
def test_skill_md_frontmatter_substitution():
|
||||
"""Test that SKILL.md placeholders are replaced."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
code, data = run_scaffold(
|
||||
tmp,
|
||||
target_dir=str(target_dir),
|
||||
module_code="xyz",
|
||||
module_name="XYZ Studio",
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
skill_md = (target_dir / "xyz-setup" / "SKILL.md").read_text()
|
||||
assert "xyz-setup" in skill_md
|
||||
assert "XYZ Studio" in skill_md
|
||||
assert "{setup-skill-name}" not in skill_md
|
||||
assert "{module-name}" not in skill_md
|
||||
assert "{module-code}" not in skill_md
|
||||
|
||||
|
||||
def test_template_frontmatter_uses_quoted_name_placeholder():
|
||||
"""Test that the template frontmatter is valid before substitution."""
|
||||
template_skill_md = (TEMPLATE_DIR / "SKILL.md").read_text()
|
||||
assert 'name: "{setup-skill-name}"' in template_skill_md
|
||||
|
||||
|
||||
def test_generated_files_written():
|
||||
"""Test that module.yaml and module-help.csv contain generated content."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
custom_yaml = 'code: abc\nname: "ABC Module"\ndescription: "Custom desc"\n'
|
||||
custom_csv = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\nABC Module,bmad-abc-thing,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,report\n"
|
||||
|
||||
code, data = run_scaffold(
|
||||
tmp,
|
||||
target_dir=str(target_dir),
|
||||
module_code="abc",
|
||||
module_name="ABC Module",
|
||||
yaml_content=custom_yaml,
|
||||
csv_content=custom_csv,
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
yaml_content = (target_dir / "abc-setup" / "assets" / "module.yaml").read_text()
|
||||
assert "ABC Module" in yaml_content
|
||||
assert "Custom desc" in yaml_content
|
||||
|
||||
csv_content = (target_dir / "abc-setup" / "assets" / "module-help.csv").read_text()
|
||||
assert "bmad-abc-thing" in csv_content
|
||||
assert "DT" in csv_content
|
||||
|
||||
|
||||
def test_anti_zombie_replaces_existing():
|
||||
"""Test that an existing setup skill is replaced cleanly."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
# First scaffold
|
||||
run_scaffold(tmp, target_dir=str(target_dir))
|
||||
stale_file = target_dir / "tst-setup" / "stale-marker.txt"
|
||||
stale_file.write_text("should be removed")
|
||||
|
||||
# Second scaffold should remove stale file
|
||||
code, data = run_scaffold(tmp, target_dir=str(target_dir))
|
||||
assert code == 0
|
||||
assert not stale_file.exists()
|
||||
|
||||
|
||||
def test_missing_target_dir():
|
||||
"""Test error when target directory doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
nonexistent = tmp / "nonexistent"
|
||||
|
||||
# Write valid source files
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
yaml_path.write_text('code: tst\nname: "Test"\n')
|
||||
csv_path.write_text("header\n")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", str(nonexistent),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
def test_missing_source_file():
|
||||
"""Test error when module.yaml source doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
target_dir = tmp / "output"
|
||||
target_dir.mkdir()
|
||||
|
||||
# Remove the yaml after creation to simulate missing file
|
||||
yaml_path = tmp / "module.yaml"
|
||||
csv_path = tmp / "module-help.csv"
|
||||
csv_path.write_text("header\n")
|
||||
# Don't create yaml_path
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--target-dir", str(target_dir),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
"--module-yaml", str(yaml_path),
|
||||
"--module-csv", str(csv_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_basic_scaffold,
|
||||
test_skill_md_frontmatter_substitution,
|
||||
test_template_frontmatter_uses_quoted_name_placeholder,
|
||||
test_generated_files_written,
|
||||
test_anti_zombie_replaces_existing,
|
||||
test_missing_target_dir,
|
||||
test_missing_source_file,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
266
.github/skills/bmad-module-builder/scripts/tests/test-scaffold-standalone-module.py
vendored
Normal file
266
.github/skills/bmad-module-builder/scripts/tests/test-scaffold-standalone-module.py
vendored
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for scaffold-standalone-module.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-standalone-module.py"
|
||||
|
||||
|
||||
def make_skill_dir(tmp: Path, name: str = "my-skill") -> Path:
|
||||
"""Create a minimal skill directory with SKILL.md and assets/module.yaml."""
|
||||
skill_dir = tmp / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n")
|
||||
assets = skill_dir / "assets"
|
||||
assets.mkdir(exist_ok=True)
|
||||
(assets / "module.yaml").write_text(
|
||||
'code: tst\nname: "Test Module"\ndescription: "A test module"\nmodule_version: 1.0.0\n'
|
||||
)
|
||||
(assets / "module-help.csv").write_text(
|
||||
"module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
"Test Module,my-skill,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
def run_scaffold(skill_dir: Path, **kwargs) -> tuple[int, dict]:
|
||||
"""Run the standalone scaffold script and return (exit_code, parsed_json)."""
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--skill-dir", str(skill_dir),
|
||||
"--module-code", kwargs.get("module_code", "tst"),
|
||||
"--module-name", kwargs.get("module_name", "Test Module"),
|
||||
]
|
||||
if "marketplace_dir" in kwargs:
|
||||
cmd.extend(["--marketplace-dir", str(kwargs["marketplace_dir"])])
|
||||
if kwargs.get("verbose"):
|
||||
cmd.append("--verbose")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, data
|
||||
|
||||
|
||||
def test_basic_scaffold():
|
||||
"""Test that scaffolding copies all expected template files."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp)
|
||||
|
||||
code, data = run_scaffold(skill_dir)
|
||||
assert code == 0, f"Script failed: {data}"
|
||||
assert data["status"] == "success"
|
||||
assert data["module_code"] == "tst"
|
||||
|
||||
# module-setup.md placed alongside module.yaml in assets/
|
||||
assert (skill_dir / "assets" / "module-setup.md").is_file()
|
||||
# merge scripts placed in scripts/
|
||||
assert (skill_dir / "scripts" / "merge-config.py").is_file()
|
||||
assert (skill_dir / "scripts" / "merge-help-csv.py").is_file()
|
||||
# marketplace.json at parent level
|
||||
assert (tmp / ".claude-plugin" / "marketplace.json").is_file()
|
||||
|
||||
|
||||
def test_marketplace_json_content():
|
||||
"""Test that marketplace.json contains correct module metadata."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp, name="bmad-exc-tools")
|
||||
|
||||
code, data = run_scaffold(
|
||||
skill_dir, module_code="exc", module_name="Excalidraw Tools"
|
||||
)
|
||||
assert code == 0
|
||||
|
||||
marketplace = json.loads(
|
||||
(tmp / ".claude-plugin" / "marketplace.json").read_text()
|
||||
)
|
||||
assert marketplace["name"] == "bmad-exc"
|
||||
plugin = marketplace["plugins"][0]
|
||||
assert plugin["name"] == "bmad-exc"
|
||||
assert plugin["skills"] == ["./bmad-exc-tools"]
|
||||
assert plugin["description"] == "A test module"
|
||||
assert plugin["version"] == "1.0.0"
|
||||
|
||||
|
||||
def test_does_not_overwrite_existing_scripts():
|
||||
"""Test that existing scripts are skipped with a warning."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp)
|
||||
|
||||
# Pre-create a merge-config.py with custom content
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
scripts_dir.mkdir(exist_ok=True)
|
||||
existing_script = scripts_dir / "merge-config.py"
|
||||
existing_script.write_text("# my custom script\n")
|
||||
|
||||
code, data = run_scaffold(skill_dir)
|
||||
assert code == 0
|
||||
|
||||
# Should be skipped
|
||||
assert "scripts/merge-config.py" in data["files_skipped"]
|
||||
assert len(data["warnings"]) >= 1
|
||||
assert any("merge-config.py" in w for w in data["warnings"])
|
||||
|
||||
# Content should be preserved
|
||||
assert existing_script.read_text() == "# my custom script\n"
|
||||
|
||||
# merge-help-csv.py should still be created
|
||||
assert "scripts/merge-help-csv.py" in data["files_created"]
|
||||
|
||||
|
||||
def test_creates_missing_subdirectories():
|
||||
"""Test that scripts/ directory is created if it doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp)
|
||||
|
||||
# Verify scripts/ doesn't exist yet
|
||||
assert not (skill_dir / "scripts").exists()
|
||||
|
||||
code, data = run_scaffold(skill_dir)
|
||||
assert code == 0
|
||||
assert (skill_dir / "scripts").is_dir()
|
||||
assert (skill_dir / "scripts" / "merge-config.py").is_file()
|
||||
|
||||
|
||||
def test_preserves_existing_skill_files():
|
||||
"""Test that existing skill files are not modified or deleted."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp)
|
||||
|
||||
# Add extra files
|
||||
(skill_dir / "build-process.md").write_text("# Build\n")
|
||||
refs_dir = skill_dir / "references"
|
||||
refs_dir.mkdir()
|
||||
(refs_dir / "my-ref.md").write_text("# Reference\n")
|
||||
|
||||
original_skill_md = (skill_dir / "SKILL.md").read_text()
|
||||
|
||||
code, data = run_scaffold(skill_dir)
|
||||
assert code == 0
|
||||
|
||||
# Original files untouched
|
||||
assert (skill_dir / "SKILL.md").read_text() == original_skill_md
|
||||
assert (skill_dir / "build-process.md").read_text() == "# Build\n"
|
||||
assert (refs_dir / "my-ref.md").read_text() == "# Reference\n"
|
||||
|
||||
|
||||
def test_missing_skill_dir():
|
||||
"""Test error when skill directory doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
nonexistent = tmp / "nonexistent-skill"
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(SCRIPT),
|
||||
"--skill-dir", str(nonexistent),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
def test_missing_skill_md():
|
||||
"""Test error when skill directory has no SKILL.md."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = tmp / "empty-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "assets").mkdir()
|
||||
(skill_dir / "assets" / "module.yaml").write_text("code: tst\n")
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(SCRIPT),
|
||||
"--skill-dir", str(skill_dir),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
assert "SKILL.md" in data["message"]
|
||||
|
||||
|
||||
def test_missing_module_yaml():
|
||||
"""Test error when assets/module.yaml hasn't been written yet."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = tmp / "skill-no-yaml"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: test\n---\n")
|
||||
|
||||
cmd = [
|
||||
sys.executable, str(SCRIPT),
|
||||
"--skill-dir", str(skill_dir),
|
||||
"--module-code", "tst",
|
||||
"--module-name", "Test",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
assert "module.yaml" in data["message"]
|
||||
|
||||
|
||||
def test_custom_marketplace_dir():
|
||||
"""Test that --marketplace-dir places marketplace.json in a custom location."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
skill_dir = make_skill_dir(tmp)
|
||||
custom_dir = tmp / "custom-root"
|
||||
custom_dir.mkdir()
|
||||
|
||||
code, data = run_scaffold(skill_dir, marketplace_dir=custom_dir)
|
||||
assert code == 0
|
||||
|
||||
# Should be at custom location, not default parent
|
||||
assert (custom_dir / ".claude-plugin" / "marketplace.json").is_file()
|
||||
assert not (tmp / ".claude-plugin" / "marketplace.json").exists()
|
||||
assert data["marketplace_json"] == str((custom_dir / ".claude-plugin" / "marketplace.json").resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_basic_scaffold,
|
||||
test_marketplace_json_content,
|
||||
test_does_not_overwrite_existing_scripts,
|
||||
test_creates_missing_subdirectories,
|
||||
test_preserves_existing_skill_files,
|
||||
test_missing_skill_dir,
|
||||
test_missing_skill_md,
|
||||
test_missing_module_yaml,
|
||||
test_custom_marketplace_dir,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
314
.github/skills/bmad-module-builder/scripts/tests/test-validate-module.py
vendored
Normal file
314
.github/skills/bmad-module-builder/scripts/tests/test-validate-module.py
vendored
Normal file
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for validate-module.py"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / "validate-module.py"
|
||||
|
||||
CSV_HEADER = "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
|
||||
|
||||
|
||||
def create_module(tmp: Path, skills: list[str] | None = None, csv_rows: str = "",
|
||||
yaml_content: str = "", setup_name: str = "tst-setup") -> Path:
|
||||
"""Create a minimal module structure for testing."""
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
|
||||
# Setup skill
|
||||
setup = module_dir / setup_name
|
||||
setup.mkdir()
|
||||
(setup / "SKILL.md").write_text("---\nname: " + setup_name + "\n---\n# Setup\n")
|
||||
(setup / "assets").mkdir()
|
||||
(setup / "assets" / "module.yaml").write_text(
|
||||
yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A test module"\n'
|
||||
)
|
||||
(setup / "assets" / "module-help.csv").write_text(CSV_HEADER + csv_rows)
|
||||
|
||||
# Other skills
|
||||
for skill in (skills or []):
|
||||
skill_dir = module_dir / skill
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(f"---\nname: {skill}\n---\n# {skill}\n")
|
||||
|
||||
return module_dir
|
||||
|
||||
|
||||
def run_validate(module_dir: Path) -> tuple[int, dict]:
|
||||
"""Run the validation script and return (exit_code, parsed_json)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), str(module_dir)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
|
||||
return result.returncode, data
|
||||
|
||||
|
||||
def test_valid_module():
|
||||
"""A well-formed module should pass."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does the foo thing,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 0, f"Expected pass: {data}"
|
||||
assert data["status"] == "pass"
|
||||
assert data["summary"]["total_findings"] == 0
|
||||
|
||||
|
||||
def test_missing_setup_skill():
|
||||
"""Module with no setup skill should fail critically."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
skill = module_dir / "tst-foo"
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text("---\nname: tst-foo\n---\n")
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
assert any(f["category"] == "structure" for f in data["findings"])
|
||||
|
||||
|
||||
def test_missing_csv_entry():
|
||||
"""Skill without a CSV entry should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_module(tmp, skills=["tst-foo", "tst-bar"],
|
||||
csv_rows='Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n')
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
missing = [f for f in data["findings"] if f["category"] == "missing-entry"]
|
||||
assert len(missing) == 1
|
||||
assert "tst-bar" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_orphan_csv_entry():
|
||||
"""CSV entry for nonexistent skill should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,tst-ghost,Ghost,GH,Does not exist,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=[], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
orphans = [f for f in data["findings"] if f["category"] == "orphan-entry"]
|
||||
assert len(orphans) == 1
|
||||
assert "tst-ghost" in orphans[0]["message"]
|
||||
|
||||
|
||||
def test_duplicate_menu_codes():
|
||||
"""Duplicate menu codes should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = (
|
||||
'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
|
||||
'Test Module,tst-foo,Also Foo,DF,Also does foo,other,,anytime,,,false,output_folder,report\n'
|
||||
)
|
||||
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
|
||||
assert len(dupes) == 1
|
||||
assert "DF" in dupes[0]["message"]
|
||||
|
||||
|
||||
def test_invalid_before_after_ref():
|
||||
"""Before/after references to nonexistent capabilities should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,tst-ghost:phantom,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
refs = [f for f in data["findings"] if f["category"] == "invalid-ref"]
|
||||
assert len(refs) == 1
|
||||
assert "tst-ghost:phantom" in refs[0]["message"]
|
||||
|
||||
|
||||
def test_missing_yaml_fields():
|
||||
"""module.yaml with missing required fields should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
csv_rows = 'Test Module,tst-foo,Do Foo,DF,Does foo,run,,anytime,,,false,output_folder,report\n'
|
||||
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows=csv_rows,
|
||||
yaml_content='code: tst\n')
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
yaml_findings = [f for f in data["findings"] if f["category"] == "yaml"]
|
||||
assert len(yaml_findings) >= 1 # at least name or description missing
|
||||
|
||||
|
||||
def test_empty_csv():
|
||||
"""CSV with header but no rows should be flagged."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_module(tmp, skills=["tst-foo"], csv_rows="")
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
empty = [f for f in data["findings"] if f["category"] == "csv-empty"]
|
||||
assert len(empty) == 1
|
||||
|
||||
|
||||
def create_standalone_module(tmp: Path, skill_name: str = "my-skill",
|
||||
csv_rows: str = "", yaml_content: str = "",
|
||||
include_setup_md: bool = True,
|
||||
include_merge_scripts: bool = True) -> Path:
|
||||
"""Create a minimal standalone module structure for testing."""
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
|
||||
skill = module_dir / skill_name
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text(f"---\nname: {skill_name}\n---\n# {skill_name}\n")
|
||||
|
||||
assets = skill / "assets"
|
||||
assets.mkdir()
|
||||
(assets / "module.yaml").write_text(
|
||||
yaml_content or 'code: tst\nname: "Test Module"\ndescription: "A standalone test module"\n'
|
||||
)
|
||||
if not csv_rows:
|
||||
csv_rows = f'Test Module,{skill_name},Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n'
|
||||
(assets / "module-help.csv").write_text(CSV_HEADER + csv_rows)
|
||||
|
||||
if include_setup_md:
|
||||
(assets / "module-setup.md").write_text("# Module Setup\nStandalone registration.\n")
|
||||
|
||||
if include_merge_scripts:
|
||||
scripts = skill / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "merge-config.py").write_text("# merge-config\n")
|
||||
(scripts / "merge-help-csv.py").write_text("# merge-help-csv\n")
|
||||
|
||||
return module_dir
|
||||
|
||||
|
||||
def test_valid_standalone_module():
|
||||
"""A well-formed standalone module should pass with standalone=true in info."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_standalone_module(tmp)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 0, f"Expected pass: {data}"
|
||||
assert data["status"] == "pass"
|
||||
assert data["info"].get("standalone") is True
|
||||
assert data["summary"]["total_findings"] == 0
|
||||
|
||||
|
||||
def test_standalone_missing_module_setup_md():
|
||||
"""Standalone module without assets/module-setup.md should fail."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_standalone_module(tmp, include_setup_md=False)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
|
||||
assert any("module-setup.md" in f["message"] for f in structure_findings)
|
||||
|
||||
|
||||
def test_standalone_missing_merge_scripts():
|
||||
"""Standalone module without merge scripts should fail."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = create_standalone_module(tmp, include_merge_scripts=False)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
structure_findings = [f for f in data["findings"] if f["category"] == "structure"]
|
||||
assert any("merge-config.py" in f["message"] for f in structure_findings)
|
||||
|
||||
|
||||
def test_standalone_csv_validation():
|
||||
"""Standalone module CSV should be validated the same as multi-skill."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
# Duplicate menu codes
|
||||
csv_rows = (
|
||||
'Test Module,my-skill,Do Thing,DT,Does thing,run,,anytime,,,false,output_folder,artifact\n'
|
||||
'Test Module,my-skill,Also Thing,DT,Also does thing,other,,anytime,,,false,output_folder,report\n'
|
||||
)
|
||||
module_dir = create_standalone_module(tmp, csv_rows=csv_rows)
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
dupes = [f for f in data["findings"] if f["category"] == "duplicate-menu-code"]
|
||||
assert len(dupes) == 1
|
||||
assert "DT" in dupes[0]["message"]
|
||||
|
||||
|
||||
def test_multi_skill_not_detected_as_standalone():
|
||||
"""A folder with two skills and no setup skill should fail (not detected as standalone)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = Path(tmp)
|
||||
module_dir = tmp / "module"
|
||||
module_dir.mkdir()
|
||||
|
||||
for name in ("skill-a", "skill-b"):
|
||||
skill = module_dir / name
|
||||
skill.mkdir()
|
||||
(skill / "SKILL.md").write_text(f"---\nname: {name}\n---\n")
|
||||
(skill / "assets").mkdir()
|
||||
(skill / "assets" / "module.yaml").write_text(f'code: tst\nname: "Test"\ndescription: "Test"\n')
|
||||
|
||||
code, data = run_validate(module_dir)
|
||||
assert code == 1
|
||||
# Should fail because it's neither a setup-skill module nor a single-skill standalone
|
||||
assert any("No setup skill found" in f["message"] for f in data["findings"])
|
||||
|
||||
|
||||
def test_nonexistent_directory():
|
||||
"""Nonexistent path should return error."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "/nonexistent/path"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 2
|
||||
data = json.loads(result.stdout)
|
||||
assert data["status"] == "error"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [
|
||||
test_valid_module,
|
||||
test_missing_setup_skill,
|
||||
test_missing_csv_entry,
|
||||
test_orphan_csv_entry,
|
||||
test_duplicate_menu_codes,
|
||||
test_invalid_before_after_ref,
|
||||
test_missing_yaml_fields,
|
||||
test_empty_csv,
|
||||
test_valid_standalone_module,
|
||||
test_standalone_missing_module_setup_md,
|
||||
test_standalone_missing_merge_scripts,
|
||||
test_standalone_csv_validation,
|
||||
test_multi_skill_not_detected_as_standalone,
|
||||
test_nonexistent_directory,
|
||||
]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
Reference in New Issue
Block a user