feat: add reminders page, BMad skills upgrade, MCP server refactor
- Add reminders page with navigation support - Upgrade BMad builder module to skills-based architecture - Refactor MCP server: extract tools and auth into separate modules - Add connections cache, custom AI provider support - Update prisma schema and generated client - Various UI/UX improvements and i18n updates - Add service worker for PWA support Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Unit tests for cleanup-legacy.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path so we can import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
# Import cleanup_legacy module
|
||||
_spec = spec_from_file_location(
|
||||
"cleanup_legacy",
|
||||
str(Path(__file__).parent.parent / "cleanup-legacy.py"),
|
||||
)
|
||||
cleanup_legacy_mod = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(cleanup_legacy_mod)
|
||||
|
||||
find_skill_dirs = cleanup_legacy_mod.find_skill_dirs
|
||||
verify_skills_installed = cleanup_legacy_mod.verify_skills_installed
|
||||
count_files = cleanup_legacy_mod.count_files
|
||||
cleanup_directories = cleanup_legacy_mod.cleanup_directories
|
||||
|
||||
|
||||
def _make_skill_dir(base, *path_parts):
|
||||
"""Create a skill directory with a SKILL.md file."""
|
||||
skill_dir = os.path.join(base, *path_parts)
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
with open(os.path.join(skill_dir, "SKILL.md"), "w") as f:
|
||||
f.write("---\nname: test-skill\n---\n# Test\n")
|
||||
return skill_dir
|
||||
|
||||
|
||||
def _make_file(base, *path_parts, content="placeholder"):
|
||||
"""Create a file at the given path."""
|
||||
file_path = os.path.join(base, *path_parts)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w") as f:
|
||||
f.write(content)
|
||||
return file_path
|
||||
|
||||
|
||||
class TestFindSkillDirs(unittest.TestCase):
|
||||
def test_finds_dirs_with_skill_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_make_skill_dir(tmpdir, "skills", "bmad-agent-builder")
|
||||
_make_skill_dir(tmpdir, "skills", "bmad-workflow-builder")
|
||||
result = find_skill_dirs(tmpdir)
|
||||
self.assertEqual(result, ["bmad-agent-builder", "bmad-workflow-builder"])
|
||||
|
||||
def test_ignores_dirs_without_skill_md(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_make_skill_dir(tmpdir, "skills", "real-skill")
|
||||
os.makedirs(os.path.join(tmpdir, "skills", "not-a-skill"))
|
||||
_make_file(tmpdir, "skills", "not-a-skill", "README.md")
|
||||
result = find_skill_dirs(tmpdir)
|
||||
self.assertEqual(result, ["real-skill"])
|
||||
|
||||
def test_empty_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = find_skill_dirs(tmpdir)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_nonexistent_directory(self):
|
||||
result = find_skill_dirs("/nonexistent/path")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_finds_nested_skills_in_phase_subdirs(self):
|
||||
"""Skills nested in phase directories like bmm/1-analysis/bmad-agent-analyst/."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_make_skill_dir(tmpdir, "1-analysis", "bmad-agent-analyst")
|
||||
_make_skill_dir(tmpdir, "2-plan", "bmad-agent-pm")
|
||||
_make_skill_dir(tmpdir, "4-impl", "bmad-agent-dev")
|
||||
result = find_skill_dirs(tmpdir)
|
||||
self.assertEqual(
|
||||
result, ["bmad-agent-analyst", "bmad-agent-dev", "bmad-agent-pm"]
|
||||
)
|
||||
|
||||
def test_deduplicates_skill_names(self):
|
||||
"""If the same skill name appears in multiple locations, only listed once."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_make_skill_dir(tmpdir, "a", "my-skill")
|
||||
_make_skill_dir(tmpdir, "b", "my-skill")
|
||||
result = find_skill_dirs(tmpdir)
|
||||
self.assertEqual(result, ["my-skill"])
|
||||
|
||||
|
||||
class TestVerifySkillsInstalled(unittest.TestCase):
|
||||
def test_all_skills_present(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
# Legacy: bmb has two skills
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-b")
|
||||
|
||||
# Installed: both exist
|
||||
os.makedirs(os.path.join(skills_dir, "skill-a"))
|
||||
os.makedirs(os.path.join(skills_dir, "skill-b"))
|
||||
|
||||
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
|
||||
self.assertEqual(result, ["skill-a", "skill-b"])
|
||||
|
||||
def test_missing_skill_exits_1(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-missing")
|
||||
|
||||
# Only skill-a installed
|
||||
os.makedirs(os.path.join(skills_dir, "skill-a"))
|
||||
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
def test_empty_legacy_dir_passes(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
os.makedirs(bmad_dir)
|
||||
os.makedirs(skills_dir)
|
||||
|
||||
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_nonexistent_legacy_dir_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
os.makedirs(skills_dir)
|
||||
# bmad_dir doesn't exist — should not error
|
||||
|
||||
result = verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_dir_without_skills_skipped(self):
|
||||
"""Directories like _config/ that have no SKILL.md are not verified."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
# _config has files but no SKILL.md
|
||||
_make_file(bmad_dir, "_config", "manifest.yaml", content="version: 1")
|
||||
_make_file(bmad_dir, "_config", "help.csv", content="a,b,c")
|
||||
os.makedirs(skills_dir)
|
||||
|
||||
result = verify_skills_installed(bmad_dir, ["_config"], skills_dir)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_verifies_across_multiple_dirs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "skill-a")
|
||||
_make_skill_dir(bmad_dir, "core", "skills", "skill-b")
|
||||
|
||||
os.makedirs(os.path.join(skills_dir, "skill-a"))
|
||||
os.makedirs(os.path.join(skills_dir, "skill-b"))
|
||||
|
||||
result = verify_skills_installed(
|
||||
bmad_dir, ["bmb", "core"], skills_dir
|
||||
)
|
||||
self.assertEqual(result, ["skill-a", "skill-b"])
|
||||
|
||||
|
||||
class TestCountFiles(unittest.TestCase):
|
||||
def test_counts_files_recursively(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_make_file(tmpdir, "a.txt")
|
||||
_make_file(tmpdir, "sub", "b.txt")
|
||||
_make_file(tmpdir, "sub", "deep", "c.txt")
|
||||
self.assertEqual(count_files(Path(tmpdir)), 3)
|
||||
|
||||
def test_empty_dir_returns_zero(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
self.assertEqual(count_files(Path(tmpdir)), 0)
|
||||
|
||||
|
||||
class TestCleanupDirectories(unittest.TestCase):
|
||||
def test_removes_single_module_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
os.makedirs(os.path.join(bmad_dir, "bmb", "skills"))
|
||||
_make_file(bmad_dir, "bmb", "skills", "SKILL.md")
|
||||
|
||||
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed, ["bmb"])
|
||||
self.assertEqual(not_found, [])
|
||||
self.assertGreater(count, 0)
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
|
||||
|
||||
def test_removes_module_core_and_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
for dirname in ("bmb", "core", "_config"):
|
||||
_make_file(bmad_dir, dirname, "some-file.txt")
|
||||
|
||||
removed, not_found, count = cleanup_directories(
|
||||
bmad_dir, ["bmb", "core", "_config"]
|
||||
)
|
||||
self.assertEqual(sorted(removed), ["_config", "bmb", "core"])
|
||||
self.assertEqual(not_found, [])
|
||||
for dirname in ("bmb", "core", "_config"):
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, dirname)))
|
||||
|
||||
def test_nonexistent_dir_in_not_found(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
os.makedirs(bmad_dir)
|
||||
|
||||
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed, [])
|
||||
self.assertEqual(not_found, ["bmb"])
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_preserves_other_module_dirs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
for dirname in ("bmb", "bmm", "tea"):
|
||||
_make_file(bmad_dir, dirname, "file.txt")
|
||||
|
||||
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed, ["bmb"])
|
||||
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmm")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "tea")))
|
||||
|
||||
def test_preserves_root_config_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
_make_file(bmad_dir, "config.yaml", content="key: val")
|
||||
_make_file(bmad_dir, "config.user.yaml", content="user: test")
|
||||
_make_file(bmad_dir, "module-help.csv", content="a,b,c")
|
||||
_make_file(bmad_dir, "bmb", "stuff.txt")
|
||||
|
||||
cleanup_directories(bmad_dir, ["bmb"])
|
||||
|
||||
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.yaml")))
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(bmad_dir, "config.user.yaml"))
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(bmad_dir, "module-help.csv"))
|
||||
)
|
||||
|
||||
def test_removes_hidden_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
_make_file(bmad_dir, "bmb", ".DS_Store")
|
||||
_make_file(bmad_dir, "bmb", "skills", ".hidden")
|
||||
|
||||
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed, ["bmb"])
|
||||
self.assertEqual(count, 2)
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
|
||||
|
||||
def test_idempotent_rerun(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
_make_file(bmad_dir, "bmb", "file.txt")
|
||||
|
||||
# First run
|
||||
removed1, not_found1, _ = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed1, ["bmb"])
|
||||
self.assertEqual(not_found1, [])
|
||||
|
||||
# Second run — idempotent
|
||||
removed2, not_found2, count2 = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed2, [])
|
||||
self.assertEqual(not_found2, ["bmb"])
|
||||
self.assertEqual(count2, 0)
|
||||
|
||||
|
||||
class TestSafetyCheck(unittest.TestCase):
|
||||
def test_no_skills_dir_skips_check(self):
|
||||
"""When --skills-dir is not provided, no verification happens."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "some-skill")
|
||||
|
||||
# No skills_dir — cleanup should proceed without verification
|
||||
removed, not_found, count = cleanup_directories(bmad_dir, ["bmb"])
|
||||
self.assertEqual(removed, ["bmb"])
|
||||
|
||||
def test_missing_skill_blocks_removal(self):
|
||||
"""When --skills-dir is provided and a skill is missing, exit 1."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "installed-skill")
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "missing-skill")
|
||||
|
||||
os.makedirs(os.path.join(skills_dir, "installed-skill"))
|
||||
# missing-skill not created in skills_dir
|
||||
|
||||
with self.assertRaises(SystemExit) as ctx:
|
||||
verify_skills_installed(bmad_dir, ["bmb"], skills_dir)
|
||||
self.assertEqual(ctx.exception.code, 1)
|
||||
|
||||
# Directory should NOT have been removed (verification failed before cleanup)
|
||||
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmb")))
|
||||
|
||||
def test_dir_without_skills_not_checked(self):
|
||||
"""Directories like _config that have no SKILL.md pass verification."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
_make_file(bmad_dir, "_config", "manifest.yaml")
|
||||
os.makedirs(skills_dir)
|
||||
|
||||
# Should not raise — _config has no skills to verify
|
||||
result = verify_skills_installed(bmad_dir, ["_config"], skills_dir)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestEndToEnd(unittest.TestCase):
|
||||
def test_full_cleanup_with_verification(self):
|
||||
"""Simulate complete cleanup flow with safety check."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
skills_dir = os.path.join(tmpdir, "skills")
|
||||
|
||||
# Create legacy structure
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-agent-builder")
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-builder-setup")
|
||||
_make_file(bmad_dir, "bmb", "skills", "bmad-agent-builder", "assets", "template.md")
|
||||
_make_skill_dir(bmad_dir, "core", "skills", "bmad-brainstorming")
|
||||
_make_file(bmad_dir, "_config", "manifest.yaml")
|
||||
_make_file(bmad_dir, "_config", "bmad-help.csv")
|
||||
|
||||
# Create root config files that must survive
|
||||
_make_file(bmad_dir, "config.yaml", content="document_output_language: English")
|
||||
_make_file(bmad_dir, "config.user.yaml", content="user_name: Test")
|
||||
_make_file(bmad_dir, "module-help.csv", content="module,name\nbmb,builder")
|
||||
|
||||
# Create other module dirs that must survive
|
||||
_make_file(bmad_dir, "bmm", "config.yaml")
|
||||
_make_file(bmad_dir, "tea", "config.yaml")
|
||||
|
||||
# Create installed skills
|
||||
os.makedirs(os.path.join(skills_dir, "bmad-agent-builder"))
|
||||
os.makedirs(os.path.join(skills_dir, "bmad-builder-setup"))
|
||||
os.makedirs(os.path.join(skills_dir, "bmad-brainstorming"))
|
||||
|
||||
# Verify
|
||||
verified = verify_skills_installed(
|
||||
bmad_dir, ["bmb", "core", "_config"], skills_dir
|
||||
)
|
||||
self.assertIn("bmad-agent-builder", verified)
|
||||
self.assertIn("bmad-builder-setup", verified)
|
||||
self.assertIn("bmad-brainstorming", verified)
|
||||
|
||||
# Cleanup
|
||||
removed, not_found, file_count = cleanup_directories(
|
||||
bmad_dir, ["bmb", "core", "_config"]
|
||||
)
|
||||
self.assertEqual(sorted(removed), ["_config", "bmb", "core"])
|
||||
self.assertEqual(not_found, [])
|
||||
self.assertGreater(file_count, 0)
|
||||
|
||||
# Verify final state
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "bmb")))
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "core")))
|
||||
self.assertFalse(os.path.exists(os.path.join(bmad_dir, "_config")))
|
||||
|
||||
# Root config files survived
|
||||
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.yaml")))
|
||||
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "config.user.yaml")))
|
||||
self.assertTrue(os.path.exists(os.path.join(bmad_dir, "module-help.csv")))
|
||||
|
||||
# Other modules survived
|
||||
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "bmm")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(bmad_dir, "tea")))
|
||||
|
||||
def test_simulate_post_merge_scripts(self):
|
||||
"""Simulate the full flow: merge scripts run first (delete config files),
|
||||
then cleanup removes directories."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bmad_dir = os.path.join(tmpdir, "_bmad")
|
||||
|
||||
# Legacy state: config files already deleted by merge scripts
|
||||
# but directories and skill content remain
|
||||
_make_skill_dir(bmad_dir, "bmb", "skills", "bmad-agent-builder")
|
||||
_make_file(bmad_dir, "bmb", "skills", "bmad-agent-builder", "refs", "doc.md")
|
||||
_make_file(bmad_dir, "bmb", ".DS_Store")
|
||||
# config.yaml already deleted by merge-config.py
|
||||
# module-help.csv already deleted by merge-help-csv.py
|
||||
|
||||
_make_skill_dir(bmad_dir, "core", "skills", "bmad-help")
|
||||
# core/config.yaml already deleted
|
||||
# core/module-help.csv already deleted
|
||||
|
||||
# Root files from merge scripts
|
||||
_make_file(bmad_dir, "config.yaml", content="bmb:\n name: BMad Builder")
|
||||
_make_file(bmad_dir, "config.user.yaml", content="user_name: Test")
|
||||
_make_file(bmad_dir, "module-help.csv", content="module,name")
|
||||
|
||||
# Cleanup directories
|
||||
removed, not_found, file_count = cleanup_directories(
|
||||
bmad_dir, ["bmb", "core"]
|
||||
)
|
||||
self.assertEqual(sorted(removed), ["bmb", "core"])
|
||||
self.assertGreater(file_count, 0)
|
||||
|
||||
# Final state: only root config files
|
||||
remaining = os.listdir(bmad_dir)
|
||||
self.assertEqual(
|
||||
sorted(remaining),
|
||||
["config.user.yaml", "config.yaml", "module-help.csv"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
644
_bmad/bmb/bmad-builder-setup/scripts/tests/test-merge-config.py
Normal file
644
_bmad/bmb/bmad-builder-setup/scripts/tests/test-merge-config.py
Normal file
@@ -0,0 +1,644 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["pyyaml"]
|
||||
# ///
|
||||
"""Unit tests for merge-config.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path so we can import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import yaml
|
||||
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
# Import merge_config module
|
||||
_spec = spec_from_file_location(
|
||||
"merge_config",
|
||||
str(Path(__file__).parent.parent / "merge-config.py"),
|
||||
)
|
||||
merge_config_mod = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(merge_config_mod)
|
||||
|
||||
extract_module_metadata = merge_config_mod.extract_module_metadata
|
||||
extract_user_settings = merge_config_mod.extract_user_settings
|
||||
merge_config = merge_config_mod.merge_config
|
||||
load_legacy_values = merge_config_mod.load_legacy_values
|
||||
apply_legacy_defaults = merge_config_mod.apply_legacy_defaults
|
||||
cleanup_legacy_configs = merge_config_mod.cleanup_legacy_configs
|
||||
apply_result_templates = merge_config_mod.apply_result_templates
|
||||
|
||||
|
||||
SAMPLE_MODULE_YAML = {
|
||||
"code": "bmb",
|
||||
"name": "BMad Builder",
|
||||
"description": "Standard Skill Compliant Factory",
|
||||
"default_selected": False,
|
||||
"bmad_builder_output_folder": {
|
||||
"prompt": "Where should skills be saved?",
|
||||
"default": "_bmad-output/skills",
|
||||
"result": "{project-root}/{value}",
|
||||
},
|
||||
"bmad_builder_reports": {
|
||||
"prompt": "Output for reports?",
|
||||
"default": "_bmad-output/reports",
|
||||
"result": "{project-root}/{value}",
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_MODULE_YAML_WITH_VERSION = {
|
||||
**SAMPLE_MODULE_YAML,
|
||||
"module_version": "1.0.0",
|
||||
}
|
||||
|
||||
SAMPLE_MODULE_YAML_WITH_USER_SETTING = {
|
||||
**SAMPLE_MODULE_YAML,
|
||||
"some_pref": {
|
||||
"prompt": "Your preference?",
|
||||
"default": "default_val",
|
||||
"user_setting": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestExtractModuleMetadata(unittest.TestCase):
|
||||
def test_extracts_metadata_fields(self):
|
||||
result = extract_module_metadata(SAMPLE_MODULE_YAML)
|
||||
self.assertEqual(result["name"], "BMad Builder")
|
||||
self.assertEqual(result["description"], "Standard Skill Compliant Factory")
|
||||
self.assertFalse(result["default_selected"])
|
||||
|
||||
def test_excludes_variable_definitions(self):
|
||||
result = extract_module_metadata(SAMPLE_MODULE_YAML)
|
||||
self.assertNotIn("bmad_builder_output_folder", result)
|
||||
self.assertNotIn("bmad_builder_reports", result)
|
||||
self.assertNotIn("code", result)
|
||||
|
||||
def test_version_present(self):
|
||||
result = extract_module_metadata(SAMPLE_MODULE_YAML_WITH_VERSION)
|
||||
self.assertEqual(result["version"], "1.0.0")
|
||||
|
||||
def test_version_absent_is_none(self):
|
||||
result = extract_module_metadata(SAMPLE_MODULE_YAML)
|
||||
self.assertIn("version", result)
|
||||
self.assertIsNone(result["version"])
|
||||
|
||||
def test_field_order(self):
|
||||
result = extract_module_metadata(SAMPLE_MODULE_YAML_WITH_VERSION)
|
||||
keys = list(result.keys())
|
||||
self.assertEqual(keys, ["name", "description", "version", "default_selected"])
|
||||
|
||||
|
||||
class TestExtractUserSettings(unittest.TestCase):
|
||||
def test_core_user_keys(self):
|
||||
answers = {
|
||||
"core": {
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"output_folder": "_bmad-output",
|
||||
},
|
||||
}
|
||||
result = extract_user_settings(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result["user_name"], "Brian")
|
||||
self.assertEqual(result["communication_language"], "English")
|
||||
self.assertNotIn("document_output_language", result)
|
||||
self.assertNotIn("output_folder", result)
|
||||
|
||||
def test_module_user_setting_true(self):
|
||||
answers = {
|
||||
"core": {"user_name": "Brian"},
|
||||
"module": {"some_pref": "custom_val"},
|
||||
}
|
||||
result = extract_user_settings(SAMPLE_MODULE_YAML_WITH_USER_SETTING, answers)
|
||||
self.assertEqual(result["user_name"], "Brian")
|
||||
self.assertEqual(result["some_pref"], "custom_val")
|
||||
|
||||
def test_no_core_answers(self):
|
||||
answers = {"module": {"some_pref": "val"}}
|
||||
result = extract_user_settings(SAMPLE_MODULE_YAML_WITH_USER_SETTING, answers)
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertEqual(result["some_pref"], "val")
|
||||
|
||||
def test_no_user_settings_in_module(self):
|
||||
answers = {
|
||||
"core": {"user_name": "Brian"},
|
||||
"module": {"bmad_builder_output_folder": "path"},
|
||||
}
|
||||
result = extract_user_settings(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result, {"user_name": "Brian"})
|
||||
|
||||
def test_empty_answers(self):
|
||||
result = extract_user_settings(SAMPLE_MODULE_YAML, {})
|
||||
self.assertEqual(result, {})
|
||||
|
||||
|
||||
class TestApplyResultTemplates(unittest.TestCase):
|
||||
def test_applies_template(self):
|
||||
answers = {"bmad_builder_output_folder": "skills"}
|
||||
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
|
||||
|
||||
def test_applies_multiple_templates(self):
|
||||
answers = {
|
||||
"bmad_builder_output_folder": "skills",
|
||||
"bmad_builder_reports": "skills/reports",
|
||||
}
|
||||
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
|
||||
self.assertEqual(result["bmad_builder_reports"], "{project-root}/skills/reports")
|
||||
|
||||
def test_skips_when_no_template(self):
|
||||
"""Variables without a result field are stored as-is."""
|
||||
yaml_no_result = {
|
||||
"code": "test",
|
||||
"my_var": {"prompt": "Enter value", "default": "foo"},
|
||||
}
|
||||
answers = {"my_var": "bar"}
|
||||
result = apply_result_templates(yaml_no_result, answers)
|
||||
self.assertEqual(result["my_var"], "bar")
|
||||
|
||||
def test_skips_when_value_already_has_project_root(self):
|
||||
"""Prevent double-prefixing if value already contains {project-root}."""
|
||||
answers = {"bmad_builder_output_folder": "{project-root}/skills"}
|
||||
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result["bmad_builder_output_folder"], "{project-root}/skills")
|
||||
|
||||
def test_empty_answers(self):
|
||||
result = apply_result_templates(SAMPLE_MODULE_YAML, {})
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_unknown_key_passed_through(self):
|
||||
"""Keys not in module.yaml are passed through unchanged."""
|
||||
answers = {"unknown_key": "some_value"}
|
||||
result = apply_result_templates(SAMPLE_MODULE_YAML, answers)
|
||||
self.assertEqual(result["unknown_key"], "some_value")
|
||||
|
||||
|
||||
class TestMergeConfig(unittest.TestCase):
|
||||
def test_fresh_install_with_core_and_module(self):
|
||||
answers = {
|
||||
"core": {
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"output_folder": "_bmad-output",
|
||||
},
|
||||
"module": {
|
||||
"bmad_builder_output_folder": "_bmad-output/skills",
|
||||
},
|
||||
}
|
||||
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only keys must NOT appear in config.yaml
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertNotIn("communication_language", result)
|
||||
# Shared core keys do appear
|
||||
self.assertEqual(result["document_output_language"], "English")
|
||||
self.assertEqual(result["output_folder"], "_bmad-output")
|
||||
self.assertEqual(result["bmb"]["name"], "BMad Builder")
|
||||
self.assertEqual(result["bmb"]["bmad_builder_output_folder"], "{project-root}/_bmad-output/skills")
|
||||
|
||||
def test_update_strips_user_keys_preserves_shared(self):
|
||||
existing = {
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"other_module": {"name": "Other"},
|
||||
}
|
||||
answers = {
|
||||
"module": {
|
||||
"bmad_builder_output_folder": "_bmad-output/skills",
|
||||
},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only keys stripped from config
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertNotIn("communication_language", result)
|
||||
# Shared core preserved at root
|
||||
self.assertEqual(result["document_output_language"], "English")
|
||||
# Other module preserved
|
||||
self.assertIn("other_module", result)
|
||||
# New module added
|
||||
self.assertIn("bmb", result)
|
||||
|
||||
def test_anti_zombie_removes_existing_module(self):
|
||||
existing = {
|
||||
"user_name": "Brian",
|
||||
"bmb": {
|
||||
"name": "BMad Builder",
|
||||
"old_variable": "should_be_removed",
|
||||
"bmad_builder_output_folder": "old/path",
|
||||
},
|
||||
}
|
||||
answers = {
|
||||
"module": {
|
||||
"bmad_builder_output_folder": "new/path",
|
||||
},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# Old variable is gone
|
||||
self.assertNotIn("old_variable", result["bmb"])
|
||||
# New value is present
|
||||
self.assertEqual(result["bmb"]["bmad_builder_output_folder"], "{project-root}/new/path")
|
||||
# Metadata is fresh from module.yaml
|
||||
self.assertEqual(result["bmb"]["name"], "BMad Builder")
|
||||
|
||||
def test_user_keys_never_written_to_config(self):
|
||||
existing = {
|
||||
"user_name": "OldName",
|
||||
"communication_language": "Spanish",
|
||||
"document_output_language": "French",
|
||||
}
|
||||
answers = {
|
||||
"core": {"user_name": "NewName", "communication_language": "English"},
|
||||
"module": {},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only keys stripped even if they were in existing config
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertNotIn("communication_language", result)
|
||||
# Shared core preserved
|
||||
self.assertEqual(result["document_output_language"], "French")
|
||||
|
||||
def test_no_core_answers_still_strips_user_keys(self):
|
||||
existing = {
|
||||
"user_name": "Brian",
|
||||
"output_folder": "/out",
|
||||
}
|
||||
answers = {
|
||||
"module": {"bmad_builder_output_folder": "path"},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only keys stripped even without core answers
|
||||
self.assertNotIn("user_name", result)
|
||||
# Shared core unchanged
|
||||
self.assertEqual(result["output_folder"], "/out")
|
||||
|
||||
def test_module_metadata_always_from_yaml(self):
|
||||
"""Module metadata comes from module.yaml, not answers."""
|
||||
answers = {
|
||||
"module": {"bmad_builder_output_folder": "path"},
|
||||
}
|
||||
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
self.assertEqual(result["bmb"]["name"], "BMad Builder")
|
||||
self.assertEqual(result["bmb"]["description"], "Standard Skill Compliant Factory")
|
||||
self.assertFalse(result["bmb"]["default_selected"])
|
||||
|
||||
def test_legacy_core_section_migrated_user_keys_stripped(self):
|
||||
"""Old config with core: nested section — user keys stripped after migration."""
|
||||
existing = {
|
||||
"core": {
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"output_folder": "/out",
|
||||
},
|
||||
"bmb": {"name": "BMad Builder"},
|
||||
}
|
||||
answers = {
|
||||
"module": {"bmad_builder_output_folder": "path"},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only keys stripped after migration
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertNotIn("communication_language", result)
|
||||
# Shared core values hoisted to root
|
||||
self.assertEqual(result["document_output_language"], "English")
|
||||
self.assertEqual(result["output_folder"], "/out")
|
||||
# Legacy core key removed
|
||||
self.assertNotIn("core", result)
|
||||
# Module still works
|
||||
self.assertIn("bmb", result)
|
||||
|
||||
def test_legacy_core_user_keys_stripped_after_migration(self):
|
||||
"""Legacy core: values get migrated, user keys stripped, shared keys kept."""
|
||||
existing = {
|
||||
"core": {"user_name": "OldName", "output_folder": "/old"},
|
||||
}
|
||||
answers = {
|
||||
"core": {"user_name": "NewName", "output_folder": "/new"},
|
||||
"module": {},
|
||||
}
|
||||
result = merge_config(existing, SAMPLE_MODULE_YAML, answers)
|
||||
|
||||
# User-only key not in config even after migration + override
|
||||
self.assertNotIn("user_name", result)
|
||||
self.assertNotIn("core", result)
|
||||
# Shared core key written
|
||||
self.assertEqual(result["output_folder"], "/new")
|
||||
|
||||
|
||||
class TestEndToEnd(unittest.TestCase):
|
||||
def test_write_and_read_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "_bmad", "config.yaml")
|
||||
|
||||
# Write answers
|
||||
answers = {
|
||||
"core": {
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"output_folder": "_bmad-output",
|
||||
},
|
||||
"module": {"bmad_builder_output_folder": "_bmad-output/skills"},
|
||||
}
|
||||
|
||||
# Run merge
|
||||
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
|
||||
merge_config_mod.write_config(result, config_path)
|
||||
|
||||
# Read back
|
||||
with open(config_path, "r") as f:
|
||||
written = yaml.safe_load(f)
|
||||
|
||||
# User-only keys not written to config.yaml
|
||||
self.assertNotIn("user_name", written)
|
||||
self.assertNotIn("communication_language", written)
|
||||
# Shared core keys written
|
||||
self.assertEqual(written["document_output_language"], "English")
|
||||
self.assertEqual(written["output_folder"], "_bmad-output")
|
||||
self.assertEqual(written["bmb"]["bmad_builder_output_folder"], "{project-root}/_bmad-output/skills")
|
||||
|
||||
def test_update_round_trip(self):
|
||||
"""Simulate install, then re-install with different values."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "config.yaml")
|
||||
|
||||
# First install
|
||||
answers1 = {
|
||||
"core": {"output_folder": "/out"},
|
||||
"module": {"bmad_builder_output_folder": "old/path"},
|
||||
}
|
||||
result1 = merge_config({}, SAMPLE_MODULE_YAML, answers1)
|
||||
merge_config_mod.write_config(result1, config_path)
|
||||
|
||||
# Second install (update)
|
||||
existing = merge_config_mod.load_yaml_file(config_path)
|
||||
answers2 = {
|
||||
"module": {"bmad_builder_output_folder": "new/path"},
|
||||
}
|
||||
result2 = merge_config(existing, SAMPLE_MODULE_YAML, answers2)
|
||||
merge_config_mod.write_config(result2, config_path)
|
||||
|
||||
# Verify
|
||||
with open(config_path, "r") as f:
|
||||
final = yaml.safe_load(f)
|
||||
|
||||
self.assertEqual(final["output_folder"], "/out")
|
||||
self.assertNotIn("user_name", final)
|
||||
self.assertEqual(final["bmb"]["bmad_builder_output_folder"], "{project-root}/new/path")
|
||||
|
||||
|
||||
class TestLoadLegacyValues(unittest.TestCase):
|
||||
def _make_legacy_dir(self, tmpdir, core_data=None, module_code=None, module_data=None):
|
||||
"""Create legacy directory structure for testing."""
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
if core_data is not None:
|
||||
core_dir = os.path.join(legacy_dir, "core")
|
||||
os.makedirs(core_dir, exist_ok=True)
|
||||
with open(os.path.join(core_dir, "config.yaml"), "w") as f:
|
||||
yaml.dump(core_data, f)
|
||||
if module_code and module_data is not None:
|
||||
mod_dir = os.path.join(legacy_dir, module_code)
|
||||
os.makedirs(mod_dir, exist_ok=True)
|
||||
with open(os.path.join(mod_dir, "config.yaml"), "w") as f:
|
||||
yaml.dump(module_data, f)
|
||||
return legacy_dir
|
||||
|
||||
def test_reads_core_keys_from_core_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = self._make_legacy_dir(tmpdir, core_data={
|
||||
"user_name": "Brian",
|
||||
"communication_language": "English",
|
||||
"document_output_language": "English",
|
||||
"output_folder": "/out",
|
||||
})
|
||||
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
|
||||
self.assertEqual(core["user_name"], "Brian")
|
||||
self.assertEqual(core["communication_language"], "English")
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(mod, {})
|
||||
|
||||
def test_reads_module_keys_matching_yaml_variables(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = self._make_legacy_dir(
|
||||
tmpdir,
|
||||
module_code="bmb",
|
||||
module_data={
|
||||
"bmad_builder_output_folder": "custom/path",
|
||||
"bmad_builder_reports": "custom/reports",
|
||||
"user_name": "Brian", # core key duplicated
|
||||
"unknown_key": "ignored", # not in module.yaml
|
||||
},
|
||||
)
|
||||
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
|
||||
self.assertEqual(mod["bmad_builder_output_folder"], "custom/path")
|
||||
self.assertEqual(mod["bmad_builder_reports"], "custom/reports")
|
||||
self.assertNotIn("unknown_key", mod)
|
||||
# Core key from module config used as fallback
|
||||
self.assertEqual(core["user_name"], "Brian")
|
||||
self.assertEqual(len(files), 1)
|
||||
|
||||
def test_core_config_takes_priority_over_module_for_core_keys(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = self._make_legacy_dir(
|
||||
tmpdir,
|
||||
core_data={"user_name": "FromCore"},
|
||||
module_code="bmb",
|
||||
module_data={"user_name": "FromModule"},
|
||||
)
|
||||
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
|
||||
self.assertEqual(core["user_name"], "FromCore")
|
||||
self.assertEqual(len(files), 2)
|
||||
|
||||
def test_no_legacy_files_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
os.makedirs(legacy_dir)
|
||||
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
|
||||
self.assertEqual(core, {})
|
||||
self.assertEqual(mod, {})
|
||||
self.assertEqual(files, [])
|
||||
|
||||
def test_ignores_other_module_directories(self):
|
||||
"""Only reads core and the specified module_code — not other modules."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = self._make_legacy_dir(
|
||||
tmpdir,
|
||||
module_code="bmb",
|
||||
module_data={"bmad_builder_output_folder": "bmb/path"},
|
||||
)
|
||||
# Create another module directory that should be ignored
|
||||
other_dir = os.path.join(legacy_dir, "cis")
|
||||
os.makedirs(other_dir)
|
||||
with open(os.path.join(other_dir, "config.yaml"), "w") as f:
|
||||
yaml.dump({"visual_tools": "advanced"}, f)
|
||||
|
||||
core, mod, files = load_legacy_values(legacy_dir, "bmb", SAMPLE_MODULE_YAML)
|
||||
self.assertNotIn("visual_tools", mod)
|
||||
self.assertEqual(len(files), 1) # only bmb, not cis
|
||||
|
||||
|
||||
class TestApplyLegacyDefaults(unittest.TestCase):
|
||||
def test_legacy_fills_missing_core(self):
|
||||
answers = {"module": {"bmad_builder_output_folder": "path"}}
|
||||
result = apply_legacy_defaults(
|
||||
answers,
|
||||
legacy_core={"user_name": "Brian", "communication_language": "English"},
|
||||
legacy_module={},
|
||||
)
|
||||
self.assertEqual(result["core"]["user_name"], "Brian")
|
||||
self.assertEqual(result["module"]["bmad_builder_output_folder"], "path")
|
||||
|
||||
def test_answers_override_legacy(self):
|
||||
answers = {
|
||||
"core": {"user_name": "NewName"},
|
||||
"module": {"bmad_builder_output_folder": "new/path"},
|
||||
}
|
||||
result = apply_legacy_defaults(
|
||||
answers,
|
||||
legacy_core={"user_name": "OldName"},
|
||||
legacy_module={"bmad_builder_output_folder": "old/path"},
|
||||
)
|
||||
self.assertEqual(result["core"]["user_name"], "NewName")
|
||||
self.assertEqual(result["module"]["bmad_builder_output_folder"], "new/path")
|
||||
|
||||
def test_legacy_fills_missing_module_keys(self):
|
||||
answers = {"module": {}}
|
||||
result = apply_legacy_defaults(
|
||||
answers,
|
||||
legacy_core={},
|
||||
legacy_module={"bmad_builder_output_folder": "legacy/path"},
|
||||
)
|
||||
self.assertEqual(result["module"]["bmad_builder_output_folder"], "legacy/path")
|
||||
|
||||
def test_empty_legacy_is_noop(self):
|
||||
answers = {"core": {"user_name": "Brian"}, "module": {"key": "val"}}
|
||||
result = apply_legacy_defaults(answers, {}, {})
|
||||
self.assertEqual(result, answers)
|
||||
|
||||
|
||||
class TestCleanupLegacyConfigs(unittest.TestCase):
|
||||
def test_deletes_module_and_core_configs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
for subdir in ("core", "bmb"):
|
||||
d = os.path.join(legacy_dir, subdir)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "config.yaml"), "w") as f:
|
||||
f.write("key: val\n")
|
||||
|
||||
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 2)
|
||||
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "core", "config.yaml")))
|
||||
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "bmb", "config.yaml")))
|
||||
# Directories still exist
|
||||
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "core")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "bmb")))
|
||||
|
||||
def test_leaves_other_module_configs_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
for subdir in ("bmb", "cis"):
|
||||
d = os.path.join(legacy_dir, subdir)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "config.yaml"), "w") as f:
|
||||
f.write("key: val\n")
|
||||
|
||||
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 1) # only bmb, not cis
|
||||
self.assertTrue(os.path.exists(os.path.join(legacy_dir, "cis", "config.yaml")))
|
||||
|
||||
def test_no_legacy_files_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
deleted = cleanup_legacy_configs(tmpdir, "bmb")
|
||||
self.assertEqual(deleted, [])
|
||||
|
||||
|
||||
class TestLegacyEndToEnd(unittest.TestCase):
|
||||
def test_full_legacy_migration(self):
|
||||
"""Simulate installing a module with legacy configs present."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
config_path = os.path.join(tmpdir, "_bmad", "config.yaml")
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
|
||||
# Create legacy core config
|
||||
core_dir = os.path.join(legacy_dir, "core")
|
||||
os.makedirs(core_dir)
|
||||
with open(os.path.join(core_dir, "config.yaml"), "w") as f:
|
||||
yaml.dump({
|
||||
"user_name": "LegacyUser",
|
||||
"communication_language": "Spanish",
|
||||
"document_output_language": "French",
|
||||
"output_folder": "/legacy/out",
|
||||
}, f)
|
||||
|
||||
# Create legacy module config
|
||||
mod_dir = os.path.join(legacy_dir, "bmb")
|
||||
os.makedirs(mod_dir)
|
||||
with open(os.path.join(mod_dir, "config.yaml"), "w") as f:
|
||||
yaml.dump({
|
||||
"bmad_builder_output_folder": "legacy/skills",
|
||||
"bmad_builder_reports": "legacy/reports",
|
||||
"user_name": "LegacyUser", # duplicated core key
|
||||
}, f)
|
||||
|
||||
# Answers from the user (only partially filled — user accepted some defaults)
|
||||
answers = {
|
||||
"core": {"user_name": "NewUser"},
|
||||
"module": {"bmad_builder_output_folder": "new/skills"},
|
||||
}
|
||||
|
||||
# Load and apply legacy
|
||||
legacy_core, legacy_module, _ = load_legacy_values(
|
||||
legacy_dir, "bmb", SAMPLE_MODULE_YAML
|
||||
)
|
||||
answers = apply_legacy_defaults(answers, legacy_core, legacy_module)
|
||||
|
||||
# Core: NewUser overrides legacy, but legacy Spanish fills in communication_language
|
||||
self.assertEqual(answers["core"]["user_name"], "NewUser")
|
||||
self.assertEqual(answers["core"]["communication_language"], "Spanish")
|
||||
|
||||
# Module: new/skills overrides, but legacy/reports fills in
|
||||
self.assertEqual(answers["module"]["bmad_builder_output_folder"], "new/skills")
|
||||
self.assertEqual(answers["module"]["bmad_builder_reports"], "legacy/reports")
|
||||
|
||||
# Merge
|
||||
result = merge_config({}, SAMPLE_MODULE_YAML, answers)
|
||||
merge_config_mod.write_config(result, config_path)
|
||||
|
||||
# Cleanup
|
||||
deleted = cleanup_legacy_configs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 2)
|
||||
self.assertFalse(os.path.exists(os.path.join(core_dir, "config.yaml")))
|
||||
self.assertFalse(os.path.exists(os.path.join(mod_dir, "config.yaml")))
|
||||
|
||||
# Verify final config — user-only keys NOT in config.yaml
|
||||
with open(config_path, "r") as f:
|
||||
final = yaml.safe_load(f)
|
||||
self.assertNotIn("user_name", final)
|
||||
self.assertNotIn("communication_language", final)
|
||||
# Shared core keys present
|
||||
self.assertEqual(final["document_output_language"], "French")
|
||||
self.assertEqual(final["output_folder"], "/legacy/out")
|
||||
self.assertEqual(final["bmb"]["bmad_builder_output_folder"], "{project-root}/new/skills")
|
||||
self.assertEqual(final["bmb"]["bmad_builder_reports"], "{project-root}/legacy/reports")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Unit tests for merge-help-csv.py."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
# Import merge_help_csv module
|
||||
from importlib.util import spec_from_file_location, module_from_spec
|
||||
|
||||
_spec = spec_from_file_location(
|
||||
"merge_help_csv",
|
||||
str(Path(__file__).parent.parent / "merge-help-csv.py"),
|
||||
)
|
||||
merge_help_csv_mod = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(merge_help_csv_mod)
|
||||
|
||||
extract_module_codes = merge_help_csv_mod.extract_module_codes
|
||||
filter_rows = merge_help_csv_mod.filter_rows
|
||||
read_csv_rows = merge_help_csv_mod.read_csv_rows
|
||||
write_csv = merge_help_csv_mod.write_csv
|
||||
cleanup_legacy_csvs = merge_help_csv_mod.cleanup_legacy_csvs
|
||||
HEADER = merge_help_csv_mod.HEADER
|
||||
|
||||
|
||||
SAMPLE_ROWS = [
|
||||
["bmb", "", "bmad-bmb-module-init", "Install Module", "IM", "install", "", "Install BMad Builder.", "anytime", "", "", "false", "", "config", ""],
|
||||
["bmb", "", "bmad-agent-builder", "Build Agent", "BA", "build-process", "", "Create an agent.", "anytime", "", "", "false", "output_folder", "agent skill", ""],
|
||||
]
|
||||
|
||||
|
||||
class TestExtractModuleCodes(unittest.TestCase):
|
||||
def test_extracts_codes(self):
|
||||
codes = extract_module_codes(SAMPLE_ROWS)
|
||||
self.assertEqual(codes, {"bmb"})
|
||||
|
||||
def test_multiple_codes(self):
|
||||
rows = SAMPLE_ROWS + [
|
||||
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
|
||||
]
|
||||
codes = extract_module_codes(rows)
|
||||
self.assertEqual(codes, {"bmb", "cis"})
|
||||
|
||||
def test_empty_rows(self):
|
||||
codes = extract_module_codes([])
|
||||
self.assertEqual(codes, set())
|
||||
|
||||
|
||||
class TestFilterRows(unittest.TestCase):
|
||||
def test_removes_matching_rows(self):
|
||||
result = filter_rows(SAMPLE_ROWS, "bmb")
|
||||
self.assertEqual(len(result), 0)
|
||||
|
||||
def test_preserves_non_matching_rows(self):
|
||||
mixed_rows = SAMPLE_ROWS + [
|
||||
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
|
||||
]
|
||||
result = filter_rows(mixed_rows, "bmb")
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0][0], "cis")
|
||||
|
||||
def test_no_match_preserves_all(self):
|
||||
result = filter_rows(SAMPLE_ROWS, "xyz")
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
|
||||
class TestReadWriteCSV(unittest.TestCase):
|
||||
def test_nonexistent_file_returns_empty(self):
|
||||
header, rows = read_csv_rows("/nonexistent/path/file.csv")
|
||||
self.assertEqual(header, [])
|
||||
self.assertEqual(rows, [])
|
||||
|
||||
def test_round_trip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test.csv")
|
||||
write_csv(path, HEADER, SAMPLE_ROWS)
|
||||
|
||||
header, rows = read_csv_rows(path)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0][0], "bmb")
|
||||
self.assertEqual(rows[0][2], "bmad-bmb-module-init")
|
||||
|
||||
def test_creates_parent_dirs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "sub", "dir", "test.csv")
|
||||
write_csv(path, HEADER, SAMPLE_ROWS)
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
|
||||
class TestEndToEnd(unittest.TestCase):
|
||||
def _write_source(self, tmpdir, rows):
|
||||
path = os.path.join(tmpdir, "source.csv")
|
||||
write_csv(path, HEADER, rows)
|
||||
return path
|
||||
|
||||
def _write_target(self, tmpdir, rows):
|
||||
path = os.path.join(tmpdir, "target.csv")
|
||||
write_csv(path, HEADER, rows)
|
||||
return path
|
||||
|
||||
def test_fresh_install_no_existing_target(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
|
||||
target_path = os.path.join(tmpdir, "target.csv")
|
||||
|
||||
# Target doesn't exist
|
||||
self.assertFalse(os.path.exists(target_path))
|
||||
|
||||
# Simulate merge
|
||||
_, source_rows = read_csv_rows(source_path)
|
||||
source_codes = extract_module_codes(source_rows)
|
||||
write_csv(target_path, HEADER, source_rows)
|
||||
|
||||
_, result_rows = read_csv_rows(target_path)
|
||||
self.assertEqual(len(result_rows), 2)
|
||||
|
||||
def test_merge_into_existing_with_other_module(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
other_rows = [
|
||||
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
|
||||
]
|
||||
target_path = self._write_target(tmpdir, other_rows)
|
||||
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
|
||||
|
||||
# Read both
|
||||
_, target_rows = read_csv_rows(target_path)
|
||||
_, source_rows = read_csv_rows(source_path)
|
||||
source_codes = extract_module_codes(source_rows)
|
||||
|
||||
# Anti-zombie filter + append
|
||||
filtered = target_rows
|
||||
for code in source_codes:
|
||||
filtered = filter_rows(filtered, code)
|
||||
merged = filtered + source_rows
|
||||
|
||||
write_csv(target_path, HEADER, merged)
|
||||
|
||||
_, result_rows = read_csv_rows(target_path)
|
||||
self.assertEqual(len(result_rows), 3) # 1 cis + 2 bmb
|
||||
|
||||
def test_anti_zombie_replaces_stale_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Existing target has old bmb entries + cis entry
|
||||
old_bmb_rows = [
|
||||
["bmb", "", "old-skill", "Old Skill", "OS", "run", "", "Old.", "anytime", "", "", "false", "", "", ""],
|
||||
["bmb", "", "another-old", "Another", "AO", "run", "", "Old too.", "anytime", "", "", "false", "", "", ""],
|
||||
]
|
||||
cis_rows = [
|
||||
["cis", "", "cis-skill", "CIS Skill", "CS", "run", "", "A skill.", "anytime", "", "", "false", "", "", ""],
|
||||
]
|
||||
target_path = self._write_target(tmpdir, old_bmb_rows + cis_rows)
|
||||
source_path = self._write_source(tmpdir, SAMPLE_ROWS)
|
||||
|
||||
# Read both
|
||||
_, target_rows = read_csv_rows(target_path)
|
||||
_, source_rows = read_csv_rows(source_path)
|
||||
source_codes = extract_module_codes(source_rows)
|
||||
|
||||
# Anti-zombie filter + append
|
||||
filtered = target_rows
|
||||
for code in source_codes:
|
||||
filtered = filter_rows(filtered, code)
|
||||
merged = filtered + source_rows
|
||||
|
||||
write_csv(target_path, HEADER, merged)
|
||||
|
||||
_, result_rows = read_csv_rows(target_path)
|
||||
# Should have 1 cis + 2 new bmb = 3 (old bmb removed)
|
||||
self.assertEqual(len(result_rows), 3)
|
||||
module_codes = [r[0] for r in result_rows]
|
||||
self.assertEqual(module_codes.count("bmb"), 2)
|
||||
self.assertEqual(module_codes.count("cis"), 1)
|
||||
# Old skills should be gone
|
||||
skill_names = [r[2] for r in result_rows]
|
||||
self.assertNotIn("old-skill", skill_names)
|
||||
self.assertNotIn("another-old", skill_names)
|
||||
|
||||
|
||||
class TestCleanupLegacyCsvs(unittest.TestCase):
|
||||
def test_deletes_module_and_core_csvs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
for subdir in ("core", "bmb"):
|
||||
d = os.path.join(legacy_dir, subdir)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "module-help.csv"), "w") as f:
|
||||
f.write("header\nrow\n")
|
||||
|
||||
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 2)
|
||||
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "core", "module-help.csv")))
|
||||
self.assertFalse(os.path.exists(os.path.join(legacy_dir, "bmb", "module-help.csv")))
|
||||
# Directories still exist
|
||||
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "core")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(legacy_dir, "bmb")))
|
||||
|
||||
def test_leaves_other_module_csvs_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
for subdir in ("bmb", "cis"):
|
||||
d = os.path.join(legacy_dir, subdir)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "module-help.csv"), "w") as f:
|
||||
f.write("header\nrow\n")
|
||||
|
||||
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 1) # only bmb, not cis
|
||||
self.assertTrue(os.path.exists(os.path.join(legacy_dir, "cis", "module-help.csv")))
|
||||
|
||||
def test_no_legacy_files_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
deleted = cleanup_legacy_csvs(tmpdir, "bmb")
|
||||
self.assertEqual(deleted, [])
|
||||
|
||||
def test_handles_only_core_no_module(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
legacy_dir = os.path.join(tmpdir, "_bmad")
|
||||
core_dir = os.path.join(legacy_dir, "core")
|
||||
os.makedirs(core_dir)
|
||||
with open(os.path.join(core_dir, "module-help.csv"), "w") as f:
|
||||
f.write("header\nrow\n")
|
||||
|
||||
deleted = cleanup_legacy_csvs(legacy_dir, "bmb")
|
||||
self.assertEqual(len(deleted), 1)
|
||||
self.assertFalse(os.path.exists(os.path.join(core_dir, "module-help.csv")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user