from __future__ import annotations import json import os from pathlib import Path import shutil import subprocess import tempfile import time import unittest HOOKS_DIR = Path(__file__).resolve().parents[1] class CheckChangelogHookTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.implementations: list[tuple[str, list[str], str]] = [] powershell = shutil.which("powershell") or shutil.which("pwsh") if powershell: cls.implementations.append( ( "powershell", [ powershell, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ], "check-changelog.ps1", ) ) bash_candidates: list[Path] = [] bash_from_path = shutil.which("bash") if bash_from_path: bash_candidates.append(Path(bash_from_path)) git_from_path = shutil.which("git") if git_from_path: bash_candidates.append(Path(git_from_path).resolve().parent.parent / "bin" / "bash.exe") bash: Path | None = None for candidate in dict.fromkeys(bash_candidates): if not candidate.is_file(): continue try: bash_probe = subprocess.run( [str(candidate), "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=10, ) except subprocess.TimeoutExpired: continue if bash_probe.returncode == 0: bash = candidate break if bash: cls.implementations.append(("bash", [str(bash)], "check-changelog.sh")) if not cls.implementations: raise unittest.SkipTest("PowerShell 和 Bash 均不可用") def _create_fixture(self, script_name: str) -> tuple[tempfile.TemporaryDirectory[str], Path]: temporary_directory = tempfile.TemporaryDirectory() root = Path(temporary_directory.name) hooks_dir = root / ".cursor" / "hooks" changelog_dir = root / ".cursor" / "changelog" hooks_dir.mkdir(parents=True) changelog_dir.mkdir(parents=True) shutil.copy2(HOOKS_DIR / script_name, hooks_dir / script_name) (hooks_dir / "changelog-scan.json").write_text( json.dumps( { "schema_version": 1, "project_type": "test", "display_name": "Test", "included_names": [], "included_extensions": [".json"], "excluded_directories": [".git", ".cursor"], }, ensure_ascii=False, indent=2, ) + "\n", encoding="utf-8", newline="\n", ) (changelog_dir / "changelog-headlines.md").write_text( "# Dev Changelog — Headlines\n", encoding="utf-8", newline="\n", ) (root / ".gitignore").write_text( ".cursor/changelog/.session-start\n", encoding="utf-8", newline="\n", ) (root / "config.json").write_text('{"value": 1}\n', encoding="utf-8", newline="\n") (root / "player.gd").write_text("extends Node\n", encoding="utf-8", newline="\n") (root / "player.luau").write_text( "--!strict\nreturn {}\n", encoding="utf-8", newline="\n", ) self._git(root, "init", "-q") self._git(root, "config", "user.email", "hook-test@example.invalid") self._git(root, "config", "user.name", "Hook Test") self._git(root, "add", ".") self._git(root, "commit", "-q", "-m", "fixture") return temporary_directory, root def _git(self, root: Path, *arguments: str) -> None: subprocess.run( ["git", "-C", str(root), *arguments], check=True, capture_output=True, text=True, encoding="utf-8", ) def _run_hook(self, command_prefix: list[str], root: Path, script_name: str) -> dict[str, str]: completed = subprocess.run( [*command_prefix, str(root / ".cursor" / "hooks" / script_name)], input="{}", check=True, capture_output=True, text=True, encoding="utf-8", timeout=30, ) return json.loads(completed.stdout) def _write_scan_config(self, root: Path, extensions: list[str]) -> None: path = root / ".cursor" / "hooks" / "changelog-scan.json" path.write_text( json.dumps( { "schema_version": 1, "project_type": "test", "display_name": "Test", "included_names": [], "included_extensions": extensions, "excluded_directories": [".git", ".cursor"], }, ensure_ascii=False, indent=2, ) + "\n", encoding="utf-8", newline="\n", ) def _set_times( self, root: Path, *, source_after_session: bool, source_name: str = "config.json", ) -> None: baseline = int(time.time()) - 20 changelog = root / ".cursor" / "changelog" / "changelog-headlines.md" session_marker = root / ".cursor" / "changelog" / ".session-start" source = root / source_name session_marker.touch() os.utime(changelog, (baseline, baseline)) os.utime(session_marker, (baseline + 5, baseline + 5)) source_time = baseline + 10 if source_after_session else baseline + 2 os.utime(source, (source_time, source_time)) def test_missing_session_marker_returns_empty(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) source = root / "config.json" future = int(time.time()) + 5 os.utime(source, (future, future)) self.assertEqual(self._run_hook(command_prefix, root, script_name), {}) def test_clean_worktree_with_newer_mtime_returns_empty(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) self._set_times(root, source_after_session=True) self.assertEqual(self._run_hook(command_prefix, root, script_name), {}) def test_uncommitted_change_newer_than_session_returns_followup(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) (root / "config.json").write_text( '{"value": 2}\n', encoding="utf-8", newline="\n", ) self._set_times(root, source_after_session=True) result = self._run_hook(command_prefix, root, script_name) self.assertIn("followup_message", result) self.assertIn("config.json", result["followup_message"]) def test_change_older_than_session_returns_empty(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) (root / "config.json").write_text( '{"value": 2}\n', encoding="utf-8", newline="\n", ) self._set_times(root, source_after_session=False) self.assertEqual(self._run_hook(command_prefix, root, script_name), {}) def test_empty_profile_never_returns_followup(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) self._write_scan_config(root, []) (root / "config.json").write_text( '{"value": 2}\n', encoding="utf-8", newline="\n", ) self._set_times(root, source_after_session=True) self.assertEqual(self._run_hook(command_prefix, root, script_name), {}) def test_godot_profile_matches_gd_but_not_unlisted_json(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) self._write_scan_config(root, [".gd"]) (root / "config.json").write_text( '{"value": 2}\n', encoding="utf-8", newline="\n", ) (root / "player.gd").write_text( "extends Node2D\n", encoding="utf-8", newline="\n", ) self._set_times( root, source_after_session=True, source_name="player.gd", ) result = self._run_hook(command_prefix, root, script_name) self.assertIn("followup_message", result) self.assertIn("player.gd", result["followup_message"]) def test_roblox_profile_matches_luau(self) -> None: for name, command_prefix, script_name in self.implementations: with self.subTest(implementation=name): temporary_directory, root = self._create_fixture(script_name) self.addCleanup(temporary_directory.cleanup) self._write_scan_config(root, [".luau"]) (root / "player.luau").write_text( "--!strict\nreturn { value = 2 }\n", encoding="utf-8", newline="\n", ) self._set_times( root, source_after_session=True, source_name="player.luau", ) result = self._run_hook(command_prefix, root, script_name) self.assertIn("followup_message", result) self.assertIn("player.luau", result["followup_message"]) if __name__ == "__main__": unittest.main()