test-scaffold-standalone-module.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.10"
  4. # ///
  5. """Tests for scaffold-standalone-module.py"""
  6. import json
  7. import subprocess
  8. import sys
  9. import tempfile
  10. from pathlib import Path
  11. SCRIPT = Path(__file__).resolve().parent.parent / "scaffold-standalone-module.py"
  12. def make_skill_dir(tmp: Path, name: str = "my-skill") -> Path:
  13. """Create a minimal skill directory with SKILL.md and assets/module.yaml."""
  14. skill_dir = tmp / name
  15. skill_dir.mkdir(parents=True, exist_ok=True)
  16. (skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n")
  17. assets = skill_dir / "assets"
  18. assets.mkdir(exist_ok=True)
  19. (assets / "module.yaml").write_text(
  20. 'code: tst\nname: "Test Module"\ndescription: "A test module"\nmodule_version: 1.0.0\n'
  21. )
  22. (assets / "module-help.csv").write_text(
  23. "module,skill,display-name,menu-code,description,action,args,phase,after,before,required,output-location,outputs\n"
  24. "Test Module,my-skill,Do Thing,DT,Does the thing,run,,anytime,,,false,output_folder,artifact\n"
  25. )
  26. return skill_dir
  27. def run_scaffold(skill_dir: Path, **kwargs) -> tuple[int, dict]:
  28. """Run the standalone scaffold script and return (exit_code, parsed_json)."""
  29. cmd = [
  30. sys.executable,
  31. str(SCRIPT),
  32. "--skill-dir", str(skill_dir),
  33. "--module-code", kwargs.get("module_code", "tst"),
  34. "--module-name", kwargs.get("module_name", "Test Module"),
  35. ]
  36. if "marketplace_dir" in kwargs:
  37. cmd.extend(["--marketplace-dir", str(kwargs["marketplace_dir"])])
  38. if kwargs.get("verbose"):
  39. cmd.append("--verbose")
  40. result = subprocess.run(cmd, capture_output=True, text=True)
  41. try:
  42. data = json.loads(result.stdout)
  43. except json.JSONDecodeError:
  44. data = {"raw_stdout": result.stdout, "raw_stderr": result.stderr}
  45. return result.returncode, data
  46. def test_basic_scaffold():
  47. """Test that scaffolding copies all expected template files."""
  48. with tempfile.TemporaryDirectory() as tmp:
  49. tmp = Path(tmp)
  50. skill_dir = make_skill_dir(tmp)
  51. code, data = run_scaffold(skill_dir)
  52. assert code == 0, f"Script failed: {data}"
  53. assert data["status"] == "success"
  54. assert data["module_code"] == "tst"
  55. # module-setup.md placed alongside module.yaml in assets/
  56. assert (skill_dir / "assets" / "module-setup.md").is_file()
  57. # merge scripts placed in scripts/
  58. assert (skill_dir / "scripts" / "merge-config.py").is_file()
  59. assert (skill_dir / "scripts" / "merge-help-csv.py").is_file()
  60. # marketplace.json at parent level
  61. assert (tmp / ".claude-plugin" / "marketplace.json").is_file()
  62. def test_marketplace_json_content():
  63. """Test that marketplace.json contains correct module metadata."""
  64. with tempfile.TemporaryDirectory() as tmp:
  65. tmp = Path(tmp)
  66. skill_dir = make_skill_dir(tmp, name="bmad-exc-tools")
  67. code, data = run_scaffold(
  68. skill_dir, module_code="exc", module_name="Excalidraw Tools"
  69. )
  70. assert code == 0
  71. marketplace = json.loads(
  72. (tmp / ".claude-plugin" / "marketplace.json").read_text()
  73. )
  74. assert marketplace["name"] == "bmad-exc"
  75. plugin = marketplace["plugins"][0]
  76. assert plugin["name"] == "bmad-exc"
  77. assert plugin["skills"] == ["./bmad-exc-tools"]
  78. assert plugin["description"] == "A test module"
  79. assert plugin["version"] == "1.0.0"
  80. def test_does_not_overwrite_existing_scripts():
  81. """Test that existing scripts are skipped with a warning."""
  82. with tempfile.TemporaryDirectory() as tmp:
  83. tmp = Path(tmp)
  84. skill_dir = make_skill_dir(tmp)
  85. # Pre-create a merge-config.py with custom content
  86. scripts_dir = skill_dir / "scripts"
  87. scripts_dir.mkdir(exist_ok=True)
  88. existing_script = scripts_dir / "merge-config.py"
  89. existing_script.write_text("# my custom script\n")
  90. code, data = run_scaffold(skill_dir)
  91. assert code == 0
  92. # Should be skipped
  93. assert "scripts/merge-config.py" in data["files_skipped"]
  94. assert len(data["warnings"]) >= 1
  95. assert any("merge-config.py" in w for w in data["warnings"])
  96. # Content should be preserved
  97. assert existing_script.read_text() == "# my custom script\n"
  98. # merge-help-csv.py should still be created
  99. assert "scripts/merge-help-csv.py" in data["files_created"]
  100. def test_creates_missing_subdirectories():
  101. """Test that scripts/ directory is created if it doesn't exist."""
  102. with tempfile.TemporaryDirectory() as tmp:
  103. tmp = Path(tmp)
  104. skill_dir = make_skill_dir(tmp)
  105. # Verify scripts/ doesn't exist yet
  106. assert not (skill_dir / "scripts").exists()
  107. code, data = run_scaffold(skill_dir)
  108. assert code == 0
  109. assert (skill_dir / "scripts").is_dir()
  110. assert (skill_dir / "scripts" / "merge-config.py").is_file()
  111. def test_preserves_existing_skill_files():
  112. """Test that existing skill files are not modified or deleted."""
  113. with tempfile.TemporaryDirectory() as tmp:
  114. tmp = Path(tmp)
  115. skill_dir = make_skill_dir(tmp)
  116. # Add extra files
  117. (skill_dir / "build-process.md").write_text("# Build\n")
  118. refs_dir = skill_dir / "references"
  119. refs_dir.mkdir()
  120. (refs_dir / "my-ref.md").write_text("# Reference\n")
  121. original_skill_md = (skill_dir / "SKILL.md").read_text()
  122. code, data = run_scaffold(skill_dir)
  123. assert code == 0
  124. # Original files untouched
  125. assert (skill_dir / "SKILL.md").read_text() == original_skill_md
  126. assert (skill_dir / "build-process.md").read_text() == "# Build\n"
  127. assert (refs_dir / "my-ref.md").read_text() == "# Reference\n"
  128. def test_missing_skill_dir():
  129. """Test error when skill directory doesn't exist."""
  130. with tempfile.TemporaryDirectory() as tmp:
  131. tmp = Path(tmp)
  132. nonexistent = tmp / "nonexistent-skill"
  133. cmd = [
  134. sys.executable, str(SCRIPT),
  135. "--skill-dir", str(nonexistent),
  136. "--module-code", "tst",
  137. "--module-name", "Test",
  138. ]
  139. result = subprocess.run(cmd, capture_output=True, text=True)
  140. assert result.returncode == 2
  141. data = json.loads(result.stdout)
  142. assert data["status"] == "error"
  143. def test_missing_skill_md():
  144. """Test error when skill directory has no SKILL.md."""
  145. with tempfile.TemporaryDirectory() as tmp:
  146. tmp = Path(tmp)
  147. skill_dir = tmp / "empty-skill"
  148. skill_dir.mkdir()
  149. (skill_dir / "assets").mkdir()
  150. (skill_dir / "assets" / "module.yaml").write_text("code: tst\n")
  151. cmd = [
  152. sys.executable, str(SCRIPT),
  153. "--skill-dir", str(skill_dir),
  154. "--module-code", "tst",
  155. "--module-name", "Test",
  156. ]
  157. result = subprocess.run(cmd, capture_output=True, text=True)
  158. assert result.returncode == 2
  159. data = json.loads(result.stdout)
  160. assert data["status"] == "error"
  161. assert "SKILL.md" in data["message"]
  162. def test_missing_module_yaml():
  163. """Test error when assets/module.yaml hasn't been written yet."""
  164. with tempfile.TemporaryDirectory() as tmp:
  165. tmp = Path(tmp)
  166. skill_dir = tmp / "skill-no-yaml"
  167. skill_dir.mkdir()
  168. (skill_dir / "SKILL.md").write_text("---\nname: test\n---\n")
  169. cmd = [
  170. sys.executable, str(SCRIPT),
  171. "--skill-dir", str(skill_dir),
  172. "--module-code", "tst",
  173. "--module-name", "Test",
  174. ]
  175. result = subprocess.run(cmd, capture_output=True, text=True)
  176. assert result.returncode == 2
  177. data = json.loads(result.stdout)
  178. assert data["status"] == "error"
  179. assert "module.yaml" in data["message"]
  180. def test_custom_marketplace_dir():
  181. """Test that --marketplace-dir places marketplace.json in a custom location."""
  182. with tempfile.TemporaryDirectory() as tmp:
  183. tmp = Path(tmp)
  184. skill_dir = make_skill_dir(tmp)
  185. custom_dir = tmp / "custom-root"
  186. custom_dir.mkdir()
  187. code, data = run_scaffold(skill_dir, marketplace_dir=custom_dir)
  188. assert code == 0
  189. # Should be at custom location, not default parent
  190. assert (custom_dir / ".claude-plugin" / "marketplace.json").is_file()
  191. assert not (tmp / ".claude-plugin" / "marketplace.json").exists()
  192. assert data["marketplace_json"] == str((custom_dir / ".claude-plugin" / "marketplace.json").resolve())
  193. if __name__ == "__main__":
  194. tests = [
  195. test_basic_scaffold,
  196. test_marketplace_json_content,
  197. test_does_not_overwrite_existing_scripts,
  198. test_creates_missing_subdirectories,
  199. test_preserves_existing_skill_files,
  200. test_missing_skill_dir,
  201. test_missing_skill_md,
  202. test_missing_module_yaml,
  203. test_custom_marketplace_dir,
  204. ]
  205. passed = 0
  206. failed = 0
  207. for test in tests:
  208. try:
  209. test()
  210. print(f" PASS: {test.__name__}")
  211. passed += 1
  212. except AssertionError as e:
  213. print(f" FAIL: {test.__name__}: {e}")
  214. failed += 1
  215. except Exception as e:
  216. print(f" ERROR: {test.__name__}: {e}")
  217. failed += 1
  218. print(f"\n{passed} passed, {failed} failed")
  219. sys.exit(1 if failed else 0)