from __future__ import annotations import importlib.util import json from pathlib import Path import shutil import sys import tempfile import unittest SCRIPT_PATH = Path(__file__).resolve().parents[1] / "tools" / "cursor_init.py" PROJECT_ROOT = SCRIPT_PATH.parents[3] sys.dont_write_bytecode = True SPEC = importlib.util.spec_from_file_location("cursor_init_under_test", SCRIPT_PATH) if SPEC is None or SPEC.loader is None: raise RuntimeError(f"无法加载 {SCRIPT_PATH}") CURSOR_INIT = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = CURSOR_INIT SPEC.loader.exec_module(CURSOR_INIT) class CursorInitTests(unittest.TestCase): def _write_text(self, root: Path, relative: str, content: str) -> None: path = root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8", newline="\n") def _write_json(self, root: Path, relative: str, data: object) -> None: self._write_text( root, relative, json.dumps(data, ensure_ascii=False, indent=2) + "\n", ) def _fixture(self) -> tuple[tempfile.TemporaryDirectory[str], Path]: temporary = tempfile.TemporaryDirectory() root = Path(temporary.name) bootstrap = ".cursor/bootstrap" self._write_json( root, f"{bootstrap}/catalog.json", { "schema_version": 1, "skill_version": 5, "common_manifest": "common.manifest.json", "skills_catalog": "skills.json", "types": [ { "id": "general", "label": "通用", "status": "ready", "manifest": "types/general/manifest.json", }, { "id": "godot", "label": "Godot", "status": "ready", "manifest": "types/godot/manifest.json", }, { "id": "roblox", "label": "Roblox", "status": "ready", "manifest": "types/roblox/manifest.json", }, ], }, ) self._write_json( root, f"{bootstrap}/common.manifest.json", { "schema_version": 1, "id": "common", "version": 1, "canonical_files": [ ".cursor/rules/common/base.mdc", ".cursor/rules/project/.gitkeep", ".cursor/skills/epee-orchestrator/SKILL.md", ".cursor/pitfalls/pitfalls.md", ], "skills": ["epee-orchestrator"], "local_env_fields": [ { "key": "shell", "example": "powershell", "default": "powershell", "required": True, "prompt": "shell", }, { "key": "changelog-author", "example": "", "default": "", "required": True, "prompt": "author", }, ], "scan": { "included_names": [], "included_extensions": [".json"], "excluded_directories": [".git", ".cursor"], }, "gitignore_fragments": ["fragments/common.gitignore"], "gitattributes_fragments": ["fragments/common.gitattributes"], "reset_files": [ { "source": "templates/pitfalls.md", "target": ".cursor/pitfalls/pitfalls.md", } ], "cleanup_globs": [".cursor/changelog/entries/**/*.md"], }, ) self._write_json( root, f"{bootstrap}/skills.json", { "schema_version": 1, "skills": [ { "id": "epee-orchestrator", "scope": "common", "type": "基础设施", "target": ".cursor/skills/epee-orchestrator", "capability": "测试 common Skill", "triggers": "测试", "output": "测试输出", }, { "id": "godot-helper", "scope": "godot", "type": "项目级", "target": ".cursor/skills/godot-helper", "capability": "测试 Godot Skill", "triggers": "Godot 测试", "output": "Godot 测试输出", }, { "id": "roblox-helper", "scope": "roblox", "type": "项目级", "target": ".cursor/skills/roblox-helper", "capability": "测试 Roblox Skill", "triggers": "Roblox 测试", "output": "测试输出", }, ], }, ) self._write_json( root, f"{bootstrap}/types/general/manifest.json", { "schema_version": 1, "id": "general", "version": 1, "readiness": {"allow_empty_payload": True}, "files": [], "trees": [], "skills": [], "local_env_fields": [], "scan": { "included_names": [], "included_extensions": [], "excluded_directories": [], }, "gitignore_fragments": [], "gitattributes_fragments": [], }, ) self._write_json( root, f"{bootstrap}/types/godot/manifest.json", { "schema_version": 1, "id": "godot", "version": 1, "readiness": { "allow_empty_payload": False, "require_scan": True, "require_gitignore": True, }, "files": [ { "source": "payload/rules/godot.mdc", "target": ".cursor/rules/project/godot.mdc", } ], "trees": [ { "source": "payload/skills/godot-helper", "target": ".cursor/skills/godot-helper", } ], "skills": ["godot-helper"], "local_env_fields": [ { "key": "godot4_path", "example": "", "default": "", "required": False, "prompt": "Godot", } ], "scan": { "included_names": ["project.godot"], "included_extensions": [".gd"], "excluded_directories": [".godot"], }, "gitignore_fragments": ["fragments/godot.gitignore"], "gitattributes_fragments": ["fragments/godot.gitattributes"], }, ) self._write_json( root, f"{bootstrap}/types/roblox/manifest.json", { "schema_version": 1, "id": "roblox", "version": 1, "readiness": { "allow_empty_payload": False, "require_scan": True, "require_gitignore": True, }, "files": [ { "source": "payload/rules/roblox.mdc", "target": ".cursor/rules/project/roblox.mdc", } ], "trees": [ { "source": "payload/skills/roblox-helper", "target": ".cursor/skills/roblox-helper", } ], "skills": ["roblox-helper"], "local_env_fields": [ { "key": "roblox_open_cloud_api_key", "example": "", "default": "", "required": False, "sensitive": True, "collect_at_init": False, "prompt": "Roblox API Key", } ], "scan": { "included_names": ["default.project.json"], "included_extensions": [".luau"], "excluded_directories": ["Packages"], }, "gitignore_fragments": ["fragments/roblox.gitignore"], "gitattributes_fragments": ["fragments/roblox.gitattributes"], }, ) self._write_text( root, f"{bootstrap}/fragments/common.gitignore", ".cursor/local-env.json\n", ) self._write_text( root, f"{bootstrap}/fragments/common.gitattributes", "*.json text eol=lf\n", ) self._write_text( root, f"{bootstrap}/templates/pitfalls.md", "# Pitfall Journal\n\n---\n", ) self._write_text( root, f"{bootstrap}/types/godot/fragments/godot.gitignore", ".godot/\n", ) self._write_text( root, f"{bootstrap}/types/godot/fragments/godot.gitattributes", "*.gd text eol=lf\n", ) self._write_text( root, f"{bootstrap}/types/godot/payload/rules/godot.mdc", "# Godot\n", ) self._write_text( root, f"{bootstrap}/types/godot/payload/skills/godot-helper/SKILL.md", "---\nname: godot-helper\ndescription: test\n---\n", ) self._write_text( root, f"{bootstrap}/types/roblox/fragments/roblox.gitignore", "Packages/\n", ) self._write_text( root, f"{bootstrap}/types/roblox/fragments/roblox.gitattributes", "*.luau text eol=lf\n", ) self._write_text( root, f"{bootstrap}/types/roblox/payload/rules/roblox.mdc", "# Roblox\n", ) self._write_text( root, f"{bootstrap}/types/roblox/payload/skills/roblox-helper/SKILL.md", "---\nname: roblox-helper\ndescription: test\n---\n", ) self._write_text(root, ".cursor/rules/common/base.mdc", "# Base\n") self._write_text(root, ".cursor/rules/project/.gitkeep", "") self._write_text( root, ".cursor/skills/epee-orchestrator/SKILL.md", "---\nname: epee-orchestrator\ndescription: test\n---\n", ) self._write_text(root, ".cursor/pitfalls/pitfalls.md", "# Old pitfalls\n") self._write_text( root, ".cursor/changelog/entries/tester/old.md", "# Old changelog\n", ) return temporary, root def _plan(self, root: Path, project_type: str): context = CURSOR_INIT.load_context(root) plan = CURSOR_INIT.build_plan( context, project_type, values={"shell": "powershell", "changelog-author": "tester"}, ) return context, plan def _apply(self, root: Path, project_type: str): context, plan = self._plan(root, project_type) self.assertEqual(plan.blockers, []) CURSOR_INIT.apply_plan(context, plan, plan.digest) return context, plan def test_list_types_includes_ready_roblox(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) context = CURSOR_INIT.load_context(root) self.assertEqual( CURSOR_INIT.list_ready_types(context), [ {"id": "general", "label": "通用"}, {"id": "godot", "label": "Godot"}, {"id": "roblox", "label": "Roblox"}, ], ) def test_empty_roblox_bundle_cannot_be_marked_ready(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) manifest_path = root / ".cursor/bootstrap/types/roblox/manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["files"] = [] manifest["trees"] = [] manifest["skills"] = [] manifest["scan"] = { "included_names": [], "included_extensions": [], "excluded_directories": [], } manifest["gitignore_fragments"] = [] manifest["gitattributes_fragments"] = [] manifest_path.write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n", ) with self.assertRaises(CURSOR_INIT.InitError): CURSOR_INIT.load_context(root) def test_general_apply_is_idempotent(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) context, _ = self._apply(root, "general") second_plan = CURSOR_INIT.build_plan( context, "general", values={"shell": "powershell", "changelog-author": "tester"}, ) self.assertEqual(second_plan.blockers, []) self.assertEqual(second_plan.actions, []) self.assertEqual(CURSOR_INIT.check_project(context), []) def test_godot_to_general_removes_type_files(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "godot") self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file()) self.assertTrue((root / ".cursor/skills/godot-helper/SKILL.md").is_file()) self._apply(root, "general") self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists()) self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists()) scan = json.loads( (root / ".cursor/hooks/changelog-scan.json").read_text(encoding="utf-8") ) self.assertEqual(scan["project_type"], "general") def test_roblox_materializes_only_for_roblox_and_switch_removes_it(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "roblox") self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file()) self.assertTrue( (root / ".cursor/skills/roblox-helper/SKILL.md").is_file() ) registry = ( root / ".cursor/skills/epee-orchestrator/registry.md" ).read_text(encoding="utf-8") self.assertIn("roblox-helper", registry) example = json.loads( (root / ".cursor/local-env.example.json").read_text(encoding="utf-8") ) self.assertEqual( example["roblox_open_cloud_api_key"], "", ) local_env = json.loads( (root / ".cursor/local-env.json").read_text(encoding="utf-8") ) self.assertEqual(local_env["roblox_open_cloud_api_key"], "") self._apply(root, "general") self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists()) self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists()) general_example = json.loads( (root / ".cursor/local-env.example.json").read_text(encoding="utf-8") ) self.assertNotIn("roblox_open_cloud_api_key", general_example) def test_godot_and_roblox_cross_switches_remove_old_bundle(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "godot") self._apply(root, "roblox") self.assertFalse((root / ".cursor/rules/project/godot.mdc").exists()) self.assertFalse((root / ".cursor/skills/godot-helper/SKILL.md").exists()) self.assertTrue((root / ".cursor/rules/project/roblox.mdc").is_file()) self._apply(root, "godot") self.assertFalse((root / ".cursor/rules/project/roblox.mdc").exists()) self.assertFalse((root / ".cursor/skills/roblox-helper/SKILL.md").exists()) self.assertTrue((root / ".cursor/rules/project/godot.mdc").is_file()) def test_sensitive_value_cannot_be_passed_with_set(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) context = CURSOR_INIT.load_context(root) fields = CURSOR_INIT._all_env_fields( context, context.types["roblox"], ) with self.assertRaisesRegex(CURSOR_INIT.InitError, "敏感字段"): CURSOR_INIT._validate_input_values( fields, {"roblox_open_cloud_api_key": "do-not-accept"}, ) def test_sensitive_local_value_is_preserved_and_not_drift(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._write_json( root, ".cursor/local-env.json", { "shell": "powershell", "changelog-author": "tester", "roblox_open_cloud_api_key": "local-secret", }, ) self._apply(root, "roblox") local_env_path = root / ".cursor/local-env.json" local_env = json.loads(local_env_path.read_text(encoding="utf-8")) self.assertEqual(local_env["roblox_open_cloud_api_key"], "local-secret") local_env["roblox_open_cloud_api_key"] = "rotated-secret" self._write_json(root, ".cursor/local-env.json", local_env) context = CURSOR_INIT.load_context(root) self.assertEqual(CURSOR_INIT.check_project(context, "roblox"), []) def test_sensitive_value_rotation_does_not_change_plan_digest(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._write_json( root, ".cursor/local-env.json", {"roblox_open_cloud_api_key": "first-secret"}, ) context, first_plan = self._plan(root, "roblox") self._write_json( root, ".cursor/local-env.json", {"roblox_open_cloud_api_key": "rotated-secret"}, ) context, rotated_plan = self._plan(root, "roblox") self.assertEqual(first_plan.digest, rotated_plan.digest) serialized = json.dumps(CURSOR_INIT.plan_as_dict(rotated_plan)) self.assertNotIn("first-secret", serialized) self.assertNotIn("rotated-secret", serialized) self.assertIn("", serialized) def test_plan_digest_mismatch_is_rejected(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) context, plan = self._plan(root, "general") with self.assertRaises(CURSOR_INIT.InitError): CURSOR_INIT.apply_plan(context, plan, "incorrect") self.assertFalse((root / ".cursor/.init-done").exists()) def test_foreign_rule_blocks_apply(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._write_text(root, ".cursor/rules/project/foreign.mdc", "# Foreign\n") context, plan = self._plan(root, "general") self.assertTrue(plan.foreign) with self.assertRaises(CURSOR_INIT.InitError): CURSOR_INIT.apply_plan(context, plan, plan.digest) self.assertFalse((root / ".cursor/.init-done").exists()) def test_modified_managed_file_blocks_type_switch(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "godot") self._write_text( root, ".cursor/rules/project/godot.mdc", "# User modified\n", ) _, plan = self._plan(root, "general") self.assertTrue( any("拒绝删除" in blocker for blocker in plan.blockers), plan.blockers, ) def test_apply_resets_state_and_removes_history(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "general") self.assertEqual( (root / ".cursor/pitfalls/pitfalls.md").read_text(encoding="utf-8"), "# Pitfall Journal\n\n---\n", ) self.assertFalse( (root / ".cursor/changelog/entries/tester/old.md").exists() ) def test_missing_common_canonical_blocks_apply(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) (root / ".cursor/rules/common/base.mdc").unlink() context, plan = self._plan(root, "general") self.assertTrue( any("缺少 common canonical" in blocker for blocker in plan.blockers), plan.blockers, ) with self.assertRaises(CURSOR_INIT.InitError): CURSOR_INIT.apply_plan(context, plan, plan.digest) self.assertFalse((root / ".cursor/.init-done").exists()) def test_skip_git_files_preserves_user_files(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._write_text(root, ".gitignore", "# user\n") context = CURSOR_INIT.load_context(root) plan = CURSOR_INIT.build_plan( context, "general", values={"shell": "powershell", "changelog-author": "tester"}, skip_git_files=True, ) self.assertEqual(plan.blockers, []) CURSOR_INIT.apply_plan(context, plan, plan.digest) self.assertEqual((root / ".gitignore").read_text(encoding="utf-8"), "# user\n") sentinel = (root / ".cursor/.init-done").read_text(encoding="utf-8") self.assertIn("gitignore_generated: false", sentinel) def test_generated_text_is_utf8_without_bom_and_uses_lf(self) -> None: temporary, root = self._fixture() self.addCleanup(temporary.cleanup) self._apply(root, "godot") generated = [ ".cursor/.init-done", ".cursor/.init-state.json", ".cursor/hooks/changelog-scan.json", ".cursor/local-env.example.json", ".cursor/local-env.json", ".cursor/skills/epee-orchestrator/registry.md", ".gitignore", ".gitattributes", ] for relative in generated: with self.subTest(path=relative): content = (root / relative).read_bytes() self.assertFalse(content.startswith(b"\xef\xbb\xbf")) self.assertNotIn(b"\r\n", content) content.decode("utf-8") def test_repository_roblox_bundle_is_ready_clean_and_complete(self) -> None: context = CURSOR_INIT.load_context(PROJECT_ROOT) roblox = context.types["roblox"] self.assertEqual(roblox.status, "ready") self.assertEqual(len(roblox.manifest["skills"]), 10) specs = CURSOR_INIT._expand_manifest_files(roblox) targets = {spec.target for spec in specs} self.assertIn( ".cursor/rules/project/roblox-luau-guardrails.mdc", targets, ) for skill_id in roblox.manifest["skills"]: with self.subTest(skill=skill_id): self.assertIn( f".cursor/skills/{skill_id}/SKILL.md", targets, ) payload_root = ( PROJECT_ROOT / ".cursor/bootstrap/types/roblox/payload" ) forbidden = ("Codex", ".codex", "xi.chen", "SwordnWizard") for path in payload_root.rglob("*"): if not path.is_file(): continue with self.subTest(path=path.relative_to(PROJECT_ROOT)): self.assertNotEqual(path.name, "openai.yaml") content = path.read_bytes() self.assertFalse(content.startswith(b"\xef\xbb\xbf")) self.assertNotIn(b"\r\n", content) text = content.decode("utf-8") for marker in forbidden: self.assertNotIn(marker, text) def test_repository_payload_applies_and_cross_switches_in_temp_target(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) shutil.copytree(PROJECT_ROOT / ".cursor", root / ".cursor") local_env_path = root / ".cursor/local-env.json" if local_env_path.exists(): local_env_path.unlink() for name in (".gitignore", ".gitattributes"): source = PROJECT_ROOT / name if source.is_file(): shutil.copy2(source, root / name) for project_type in ("roblox", "general", "godot", "roblox"): context = CURSOR_INIT.load_context(root) plan = CURSOR_INIT.build_plan( context, project_type, values={ "shell": "powershell", "changelog-author": "integration-test", }, ) self.assertEqual(plan.blockers, [], project_type) CURSOR_INIT.apply_plan(context, plan, plan.digest) checked_context = CURSOR_INIT.load_context(root) self.assertEqual( CURSOR_INIT.check_project(checked_context, project_type), [], project_type, ) roblox_skill = ( root / ".cursor/skills/roblox-project-initializer/SKILL.md" ) if project_type == "roblox": self.assertTrue(roblox_skill.is_file()) example = json.loads( ( root / ".cursor/local-env.example.json" ).read_text(encoding="utf-8") ) self.assertEqual( example["roblox_open_cloud_api_key"], "", ) else: self.assertFalse(roblox_skill.exists()) example = json.loads( ( root / ".cursor/local-env.example.json" ).read_text(encoding="utf-8") ) self.assertNotIn("roblox_open_cloud_api_key", example) if __name__ == "__main__": unittest.main()