c6af9e284a
Tests / catch-all (windows-latest) (push) Has been cancelled
Tests / jvm (macos-latest) (push) Has been cancelled
Tests / jvm (ubuntu-latest) (push) Has been cancelled
Tests / jvm (windows-latest) (push) Has been cancelled
Tests / native (macos-latest) (push) Has been cancelled
Tests / native (ubuntu-latest) (push) Has been cancelled
Tests / native (windows-latest) (push) Has been cancelled
Tests / niche (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (macos-latest) (push) Has been cancelled
Tests / other-langs (ubuntu-latest) (push) Has been cancelled
Tests / other-langs (windows-latest) (push) Has been cancelled
Tests / catch-all (macos-latest) (push) Has been cancelled
Tests / catch-all (ubuntu-latest) (push) Has been cancelled
Docs Build / build (push) Has been cancelled
Docs Build / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Build and Push Docker Images / build-and-push (push) Has been cancelled
376 lines
17 KiB
Python
376 lines
17 KiB
Python
"""Tests for CLI project commands (create, index)."""
|
|
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from serena.cli import ProjectCommands, TopLevelCommands, find_project_root
|
|
from serena.config.serena_config import ProjectConfig
|
|
|
|
pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_project_dir():
|
|
"""Create a temporary directory for testing."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
yield tmpdir
|
|
finally:
|
|
# if windows, wait a bit to avoid PermissionError on cleanup
|
|
if os.name == "nt":
|
|
time.sleep(0.2)
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_project_dir_with_python_file():
|
|
"""Create a temporary directory with a Python file for testing."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
try:
|
|
# Create a simple Python file so language detection works
|
|
py_file = os.path.join(tmpdir, "test.py")
|
|
with open(py_file, "w") as f:
|
|
f.write("def hello():\n pass\n")
|
|
yield tmpdir
|
|
finally:
|
|
# if windows, wait a bit to avoid PermissionError on cleanup
|
|
if os.name == "nt":
|
|
time.sleep(0.2)
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def cli_runner():
|
|
"""Create a CliRunner for testing Click commands."""
|
|
return CliRunner()
|
|
|
|
|
|
class TestProjectCreate:
|
|
"""Tests for 'project create' command."""
|
|
|
|
def test_create_basic_with_language(self, cli_runner, temp_project_dir):
|
|
"""Test basic project creation with explicit language."""
|
|
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
assert "python" in result.output.lower()
|
|
|
|
# Verify project.yml was created
|
|
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path), f"project.yml not found at {yml_path}"
|
|
|
|
def test_create_auto_detect_language(self, cli_runner, temp_project_dir_with_python_file):
|
|
"""Test project creation with auto-detected language."""
|
|
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir_with_python_file])
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
assert "python" in result.output.lower()
|
|
|
|
# Verify project.yml was created
|
|
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path)
|
|
|
|
def test_create_with_name(self, cli_runner, temp_project_dir):
|
|
"""Test project creation with custom name and explicit language."""
|
|
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--name", "my-custom-project", "--language", "python"])
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
|
|
# Verify project.yml was created
|
|
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path)
|
|
|
|
def test_create_with_language(self, cli_runner, temp_project_dir):
|
|
"""Test project creation with specified language."""
|
|
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
assert "python" in result.output.lower()
|
|
|
|
def test_create_with_multiple_languages(self, cli_runner, temp_project_dir):
|
|
"""Test project creation with multiple languages."""
|
|
result = cli_runner.invoke(
|
|
ProjectCommands.create,
|
|
[temp_project_dir, "--language", "python", "--language", "typescript"],
|
|
)
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
|
|
def test_create_with_invalid_language(self, cli_runner, temp_project_dir):
|
|
"""Test project creation with invalid language raises error."""
|
|
result = cli_runner.invoke(
|
|
ProjectCommands.create,
|
|
[temp_project_dir, "--language", "invalid-lang"],
|
|
)
|
|
assert result.exit_code != 0, "Should fail with invalid language"
|
|
assert "Unknown language" in result.output or "invalid-lang" in result.output
|
|
|
|
def test_create_already_exists(self, cli_runner, temp_project_dir):
|
|
"""Test that creating a project twice fails gracefully."""
|
|
# Create once with explicit language
|
|
result1 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
|
|
assert result1.exit_code == 0
|
|
|
|
# Try to create again - should fail gracefully
|
|
result2 = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
|
|
assert result2.exit_code != 0, "Should fail when project.yml already exists"
|
|
assert "already exists" in result2.output.lower()
|
|
assert "Error:" in result2.output # Should be user-friendly error
|
|
|
|
def test_create_with_index_flag(self, cli_runner, temp_project_dir_with_python_file):
|
|
"""Test project creation with --index flag performs indexing."""
|
|
result = cli_runner.invoke(
|
|
ProjectCommands.create,
|
|
[temp_project_dir_with_python_file, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"],
|
|
)
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Generated project" in result.output
|
|
assert "Indexing project" in result.output
|
|
|
|
# Verify project.yml was created
|
|
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path)
|
|
|
|
# Verify cache directory was created (proof of indexing)
|
|
cache_dir = os.path.join(temp_project_dir_with_python_file, ".serena", "cache")
|
|
assert os.path.exists(cache_dir), "Cache directory should exist after indexing"
|
|
|
|
def test_create_without_index_flag(self, cli_runner, temp_project_dir):
|
|
"""Test that project creation without --index does NOT perform indexing."""
|
|
result = cli_runner.invoke(ProjectCommands.create, [temp_project_dir, "--language", "python"])
|
|
assert result.exit_code == 0
|
|
assert "Generated project" in result.output
|
|
assert "Indexing" not in result.output
|
|
|
|
# Verify cache directory was NOT created
|
|
cache_dir = os.path.join(temp_project_dir, ".serena", "cache")
|
|
assert not os.path.exists(cache_dir), "Cache directory should not exist without --index"
|
|
|
|
|
|
class TestProjectIndex:
|
|
"""Tests for 'project index' command."""
|
|
|
|
def test_index_auto_creates_project_with_files(self, cli_runner, temp_project_dir_with_python_file):
|
|
"""Test that index command auto-creates project.yml if it doesn't exist (with source files)."""
|
|
result = cli_runner.invoke(ProjectCommands.index, [temp_project_dir_with_python_file, "--log-level", "ERROR", "--timeout", "5"])
|
|
# Should succeed and perform indexing
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
assert "Auto-creating" in result.output or "Indexing" in result.output
|
|
|
|
# Verify project.yml was auto-created
|
|
yml_path = os.path.join(temp_project_dir_with_python_file, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path), "project.yml should be auto-created"
|
|
|
|
def test_index_with_explicit_language(self, cli_runner, temp_project_dir):
|
|
"""Test index with explicit --language for empty directory."""
|
|
result = cli_runner.invoke(
|
|
ProjectCommands.index,
|
|
[temp_project_dir, "--language", "python", "--log-level", "ERROR", "--timeout", "5"],
|
|
)
|
|
# Should succeed even without source files if language is explicit
|
|
assert result.exit_code == 0, f"Command failed: {result.output}"
|
|
|
|
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path)
|
|
|
|
def test_index_with_language_auto_creates(self, cli_runner, temp_project_dir):
|
|
"""Test index with --language option for auto-creation."""
|
|
result = cli_runner.invoke(
|
|
ProjectCommands.index,
|
|
[temp_project_dir, "--language", "python", "--log-level", "ERROR"],
|
|
)
|
|
assert result.exit_code == 0 or "Indexing" in result.output
|
|
|
|
yml_path = os.path.join(temp_project_dir, ".serena", "project.yml")
|
|
assert os.path.exists(yml_path)
|
|
|
|
def test_index_is_equivalent_to_create_with_index(self, cli_runner, temp_project_dir_with_python_file):
|
|
"""Test that 'index' behaves like 'create --index' for new projects."""
|
|
# Use manual temp directory creation with Windows-safe cleanup
|
|
# to avoid PermissionError on Windows CI when language servers hold file locks
|
|
dir1 = tempfile.mkdtemp()
|
|
dir2 = tempfile.mkdtemp()
|
|
|
|
try:
|
|
# Setup both directories with same file
|
|
for d in [dir1, dir2]:
|
|
with open(os.path.join(d, "test.py"), "w") as f:
|
|
f.write("def hello():\n pass\n")
|
|
|
|
# Run 'create --index' on dir1
|
|
result1 = cli_runner.invoke(
|
|
ProjectCommands.create, [dir1, "--language", "python", "--index", "--log-level", "ERROR", "--timeout", "5"]
|
|
)
|
|
|
|
# Run 'index' on dir2
|
|
result2 = cli_runner.invoke(ProjectCommands.index, [dir2, "--language", "python", "--log-level", "ERROR", "--timeout", "5"])
|
|
|
|
# Both should succeed
|
|
assert result1.exit_code == 0, f"create --index failed: {result1.output}"
|
|
assert result2.exit_code == 0, f"index failed: {result2.output}"
|
|
|
|
# Both should create project.yml
|
|
assert os.path.exists(os.path.join(dir1, ".serena", "project.yml"))
|
|
assert os.path.exists(os.path.join(dir2, ".serena", "project.yml"))
|
|
|
|
# Both should create cache (proof of indexing)
|
|
assert os.path.exists(os.path.join(dir1, ".serena", "cache"))
|
|
assert os.path.exists(os.path.join(dir2, ".serena", "cache"))
|
|
finally:
|
|
# Windows-safe cleanup: wait for file handles to be released
|
|
if os.name == "nt":
|
|
time.sleep(0.2)
|
|
# Use ignore_errors to handle lingering file locks on Windows
|
|
shutil.rmtree(dir1, ignore_errors=True)
|
|
shutil.rmtree(dir2, ignore_errors=True)
|
|
|
|
|
|
class TestProjectCreateHelper:
|
|
"""Tests for _create_project helper method."""
|
|
|
|
def test_create_project_helper_returns_config(self, temp_project_dir):
|
|
"""Test that _create_project returns a ProjectConfig with explicit language."""
|
|
config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)).project_config
|
|
assert isinstance(config, ProjectConfig)
|
|
assert config.project_name == "test-project"
|
|
|
|
def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file):
|
|
"""Test _create_project with auto-detected language."""
|
|
config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()).project_config
|
|
assert isinstance(config, ProjectConfig)
|
|
assert config.project_name == "my-project"
|
|
assert len(config.languages) >= 1
|
|
|
|
def test_create_project_helper_with_languages(self, temp_project_dir):
|
|
"""Test _create_project with language specification."""
|
|
config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")).project_config
|
|
assert isinstance(config, ProjectConfig)
|
|
assert len(config.languages) >= 1
|
|
|
|
def test_create_project_helper_file_exists_error(self, temp_project_dir):
|
|
"""Test _create_project raises error if project.yml exists."""
|
|
# Create project first with explicit language
|
|
ProjectCommands._create_project(temp_project_dir, None, ("python",))
|
|
|
|
# Try to create again - should raise FileExistsError
|
|
with pytest.raises(FileExistsError):
|
|
ProjectCommands._create_project(temp_project_dir, None, ("python",))
|
|
|
|
|
|
class TestFindProjectRoot:
|
|
"""Tests for find_project_root helper with virtual chroot boundary."""
|
|
|
|
def test_finds_serena_from_subdirectory(self, temp_project_dir):
|
|
"""Test that .serena/project.yml is found when searching from a subdirectory."""
|
|
serena_dir = os.path.join(temp_project_dir, ".serena")
|
|
os.makedirs(serena_dir)
|
|
Path(os.path.join(serena_dir, "project.yml")).touch()
|
|
subdir = os.path.join(temp_project_dir, "src", "nested")
|
|
os.makedirs(subdir)
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(subdir)
|
|
result = find_project_root(root=temp_project_dir)
|
|
assert result is not None
|
|
assert os.path.samefile(result, temp_project_dir)
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
def test_serena_preferred_over_git(self, temp_project_dir):
|
|
"""Test that .serena/project.yml takes priority over .git at the same level."""
|
|
serena_dir = os.path.join(temp_project_dir, ".serena")
|
|
os.makedirs(serena_dir)
|
|
Path(os.path.join(serena_dir, "project.yml")).touch()
|
|
os.makedirs(os.path.join(temp_project_dir, ".git"))
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(temp_project_dir)
|
|
result = find_project_root(root=temp_project_dir)
|
|
assert result is not None
|
|
assert os.path.isdir(os.path.join(result, ".serena"))
|
|
assert os.path.samefile(result, temp_project_dir)
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
def test_git_used_as_fallback(self, temp_project_dir):
|
|
"""Test that .git is found when no .serena exists."""
|
|
os.makedirs(os.path.join(temp_project_dir, ".git"))
|
|
subdir = os.path.join(temp_project_dir, "src")
|
|
os.makedirs(subdir)
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(subdir)
|
|
result = find_project_root(root=temp_project_dir)
|
|
assert result is not None
|
|
assert os.path.samefile(result, temp_project_dir)
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
def test_falls_back_to_none_when_no_markers(self, temp_project_dir):
|
|
"""Test falls back to None when no markers exist within boundary."""
|
|
subdir = os.path.join(temp_project_dir, "src")
|
|
os.makedirs(subdir)
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(subdir)
|
|
result = find_project_root(root=temp_project_dir)
|
|
assert result is None
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
def test_git_worktree_not_hijacked_by_ancestor_serena(self, temp_project_dir):
|
|
"""A git worktree nested under a Serena project must resolve to the worktree.
|
|
|
|
Regression test: when a git worktree (whose .git is a pointer *file*) lives
|
|
below a directory that is an explicit Serena project (.serena/project.yml),
|
|
the worktree's own .git boundary must win over the ancestor's project marker.
|
|
The old two-pass search returned the ancestor Serena project, causing reads
|
|
and edits to land in the wrong working tree.
|
|
"""
|
|
# Ancestor directory is an explicit Serena project.
|
|
serena_dir = os.path.join(temp_project_dir, ".serena")
|
|
os.makedirs(serena_dir)
|
|
Path(os.path.join(serena_dir, "project.yml")).touch()
|
|
# Nested git worktree: .git is a gitdir pointer file, as created by `git worktree add`.
|
|
worktree = os.path.join(temp_project_dir, "nested", "worktree")
|
|
os.makedirs(worktree)
|
|
Path(os.path.join(worktree, ".git")).write_text("gitdir: /repo/.git/worktrees/wt\n")
|
|
|
|
original_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(worktree)
|
|
result = find_project_root(root=temp_project_dir)
|
|
assert result is not None
|
|
assert os.path.samefile(result, worktree)
|
|
finally:
|
|
os.chdir(original_cwd)
|
|
|
|
|
|
class TestProjectFromCwdMutualExclusivity:
|
|
"""Tests for --project-from-cwd mutual exclusivity."""
|
|
|
|
def test_project_from_cwd_with_project_flag_fails(self, cli_runner):
|
|
"""Test that --project-from-cwd with --project raises error."""
|
|
result = cli_runner.invoke(
|
|
TopLevelCommands.start_mcp_server,
|
|
["--project-from-cwd", "--project", "/some/path"],
|
|
)
|
|
assert result.exit_code != 0
|
|
assert "cannot be used with" in result.output
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# For manual testing, you can run this file directly:
|
|
# uv run pytest test/serena/test_cli_project_commands.py -v
|
|
pytest.main([__file__, "-v"])
|