From 29fafc39db54ecb96e628768eb47674448005e89 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Thu, 23 Apr 2026 09:00:44 +0800 Subject: [PATCH] test: add pytest suite for core modules - Add pytest dependency and config to pyproject.toml - Test project_root: env var priority, fallback, directory creation - Test CLI: help, version, subcommands, default behavior, options --- pyproject.toml | 7 +++ tests/__init__.py | 0 tests/test_cli.py | 99 ++++++++++++++++++++++++++++++++++++++ tests/test_project_root.py | 80 ++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_project_root.py diff --git a/pyproject.toml b/pyproject.toml index 7271432..1fafc7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,13 @@ dependencies = [ [project.scripts] myclaude = "myclaude.cli:cli" +[dependency-groups] +dev = ["pytest>=8.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e749d20 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,99 @@ +"""Tests for myclaude.cli.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from myclaude.cli import cli + + +class TestCliHelp: + """Tests for CLI help and basic invocation.""" + + def test_help_shows_options(self) -> None: + """--help should show cwd and browser control options.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "--cwd" in result.output + assert "--dangerously-allow-browser-control" in result.output + + def test_version_shows_version(self) -> None: + """--version should show package version.""" + runner = CliRunner() + result = runner.invoke(cli, ["--version"]) + assert result.exit_code == 0 + assert "version" in result.output.lower() + + def test_update_subcommand_exists(self) -> None: + """update subcommand should be registered.""" + runner = CliRunner() + result = runner.invoke(cli, ["update", "--help"]) + assert result.exit_code == 0 + assert "update" in result.output.lower() or "reinstall" in result.output.lower() + + def test_upgrade_alias_exists(self) -> None: + """upgrade should be an alias for update.""" + runner = CliRunner() + result = runner.invoke(cli, ["upgrade", "--help"]) + assert result.exit_code == 0 + + +class TestCliDefaultBehavior: + """Tests for default chat behavior.""" + + def test_no_subcommand_runs_claude(self) -> None: + """Running without subcommand should invoke claude binary.""" + runner = CliRunner() + + with ( + patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), + patch("myclaude.cli.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, []) + assert result.exit_code == 0 + mock_run.assert_called_once() + call_args = mock_run.call_args + assert call_args[0][0] == ["/usr/bin/claude"] + + def test_missing_claude_binary_error(self) -> None: + """Should exit with error when claude binary not found.""" + runner = CliRunner() + + with patch("myclaude.cli.shutil.which", return_value=None): + result = runner.invoke(cli, []) + assert result.exit_code == 127 + assert "not found" in result.output.lower() + + def test_cwd_option_passed(self, tmp_path: Path) -> None: + """--cwd should be passed as working directory.""" + runner = CliRunner() + test_dir = tmp_path / "test_cwd" + test_dir.mkdir() + + with ( + patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), + patch("myclaude.cli.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["--cwd", str(test_dir)]) + assert result.exit_code == 0 + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs.get("cwd") == str(test_dir.resolve()) + + def test_dangerously_allow_browser_control_flag(self) -> None: + """--dangerously-allow-browser-control should be forwarded.""" + runner = CliRunner() + + with ( + patch("myclaude.cli.shutil.which", return_value="/usr/bin/claude"), + patch("myclaude.cli.subprocess.run") as mock_run, + ): + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ["--dangerously-allow-browser-control"]) + assert result.exit_code == 0 + call_args = mock_run.call_args[0][0] + assert "--dangerously-allow-browser-control" in call_args + diff --git a/tests/test_project_root.py b/tests/test_project_root.py new file mode 100644 index 0000000..b01996c --- /dev/null +++ b/tests/test_project_root.py @@ -0,0 +1,80 @@ +"""Tests for myclaude.project_root.""" + +import os +from pathlib import Path +from unittest.mock import patch + +from myclaude.project_root import get_myclaude_project_root, get_workspace_root + + +class TestGetMyclaudeProjectRoot: + """Tests for get_myclaude_project_root.""" + + def test_env_var_takes_priority(self, tmp_path: Path) -> None: + """MYCLAUDE_PROJECT_ROOT env var should be used when set.""" + fake_root = tmp_path / "fake_repo" + fake_root.mkdir() + (fake_root / "pyproject.toml").write_text('name = "myclaude"\n') + + with patch.dict(os.environ, {"MYCLAUDE_PROJECT_ROOT": str(fake_root)}): + result = get_myclaude_project_root() + assert result == fake_root.resolve() + + def test_fallback_when_not_in_repo(self) -> None: + """When not in a repo, fallback to ~/.myclaude.""" + with patch.dict(os.environ, {}, clear=True): + with patch("pathlib.Path.cwd", side_effect=OSError): + with patch("myclaude.project_root._pyproject_names_myclaude", return_value=False): + result = get_myclaude_project_root() + assert result == Path.home() / ".myclaude" + + +class TestGetWorkspaceRoot: + """Tests for get_workspace_root.""" + + def test_env_var_takes_priority(self, tmp_path: Path) -> None: + """MYCLAUDE_WORKSPACE_ROOT env var should be used when set.""" + custom = tmp_path / "custom_workspace" + + with patch.dict(os.environ, {"MYCLAUDE_WORKSPACE_ROOT": str(custom)}): + result = get_workspace_root() + assert result == custom.resolve() + assert result.is_dir() + + def test_creates_directory_if_missing(self, tmp_path: Path) -> None: + """Should create the workspace directory if it doesn't exist.""" + new_ws = tmp_path / "new_workspace" + assert not new_ws.exists() + + with patch.dict(os.environ, {"MYCLAUDE_WORKSPACE_ROOT": str(new_ws)}): + result = get_workspace_root() + assert result == new_ws.resolve() + assert result.is_dir() + + def test_defaults_to_project_workspace(self, tmp_path: Path) -> None: + """When project_root/workspace exists, use it.""" + project_root = tmp_path / "myclaude" + project_root.mkdir() + workspace = project_root / "workspace" + workspace.mkdir() + (project_root / "pyproject.toml").write_text('name = "myclaude"\n') + + with patch.dict(os.environ, {"MYCLAUDE_PROJECT_ROOT": str(project_root)}, clear=True): + result = get_workspace_root() + assert result == workspace.resolve() + + def test_defaults_to_home_workspace(self, tmp_path: Path) -> None: + """When no env var and no project workspace, default to ~/workspace.""" + with ( + patch.dict(os.environ, {}, clear=True), + patch("myclaude.project_root.get_myclaude_project_root", return_value=tmp_path / ".myclaude"), + patch.dict(os.environ, {}, clear=True), + ): + with patch.dict(os.environ, {}, clear=True): + # Ensure HOME is set for Path.home() + home = tmp_path / "home" + home.mkdir() + with patch("pathlib.Path.home", return_value=home): + result = get_workspace_root() + assert result == home / "workspace" + assert result.is_dir()