diff --git a/Makefile b/Makefile index 1da945d..4d61733 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ typecheck \ quality \ test \ + test-unit \ + test-integration \ + test-e2e \ cov \ check \ bench \ @@ -42,6 +45,15 @@ quality: format-check lint typecheck test: poetry run pytest +test-unit: + poetry run pytest tests/unit + +test-integration: + poetry run pytest tests/integration + +test-e2e: + poetry run pytest tests/e2e + cov: poetry run pytest \ --cov=pcd_cli \ diff --git a/src/pcd_cli/cli/config.py b/src/pcd_cli/cli/config.py index 6f57c9f..5b61b10 100644 --- a/src/pcd_cli/cli/config.py +++ b/src/pcd_cli/cli/config.py @@ -40,8 +40,8 @@ def edit_config(catalog: ProjectCatalog) -> None: editor = catalog.config.load().editor except InvalidConfigError: editor = None - else: - editor = editor or os.environ.get("VISUAL") or os.environ.get("EDITOR") + + editor = editor or os.environ.get("VISUAL") or os.environ.get("EDITOR") if editor is None: raise click.UsageError("Set $VISUAL or $EDITOR before running `pcd config edit`") diff --git a/tests/e2e/test_cli_workflow.py b/tests/e2e/test_cli_workflow.py new file mode 100644 index 0000000..601c9fb --- /dev/null +++ b/tests/e2e/test_cli_workflow.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import os +import pty +import subprocess +import sys +from pathlib import Path + + +def _pcd_executable() -> Path: + return Path(sys.executable).with_name("pcd") + + +def _environment(tmp_path: Path) -> dict[str, str]: + environment = os.environ.copy() + environment.update( + { + "HOME": str(tmp_path / "home"), + "XDG_CACHE_HOME": str(tmp_path / "cache"), + "XDG_CONFIG_HOME": str(tmp_path / "config"), + "XDG_STATE_HOME": str(tmp_path / "state"), + "PATH": f"{_pcd_executable().parent}{os.pathsep}{environment['PATH']}", + "SHELL": "/bin/bash", + } + ) + Path(environment["HOME"]).mkdir(exist_ok=True) + return environment + + +def test_cli_discovers_and_lists_project(tmp_path: Path) -> None: + root = tmp_path / "projects" + (root / "repo/.git").mkdir(parents=True) + environment = _environment(tmp_path) + + initialized = subprocess.run( + [_pcd_executable(), "init"], + cwd=root, + env=environment, + check=False, + capture_output=True, + text=True, + ) + listed = subprocess.run( + [_pcd_executable(), "list"], + cwd=root, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert initialized.returncode == 0 + assert "Added root" in initialized.stdout + assert listed.returncode == 0 + assert "repo" in listed.stdout + assert "scanned" in listed.stdout + assert "available" in listed.stdout + + +def test_bash_wrapper_changes_parent_shell_directory(tmp_path: Path) -> None: + root = tmp_path / "projects" + repo = root / "repo" + (repo / ".git").mkdir(parents=True) + environment = _environment(tmp_path) + subprocess.run([_pcd_executable(), "init"], cwd=root, env=environment, check=True) + + result = subprocess.run( + [ + "bash", + "-c", + 'eval "$("$1" shell print bash)"; pcd repo; pwd', + "pcd-test", + str(_pcd_executable()), + ], + cwd=root, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == str(repo) + + +def test_config_edit_keeps_terminal_attached_through_bash_wrapper(tmp_path: Path) -> None: + environment = _environment(tmp_path) + marker = tmp_path / "editor-opened" + editor = tmp_path / "editor" + editor.write_text( + "#!/bin/sh\n" + "if [ -t 0 ] && [ -t 1 ] && [ -t 2 ]; then\n" + ' : > "$PCD_TEST_MARKER"\n' + " exit 0\n" + "fi\n" + "exit 91\n", + encoding="utf-8", + ) + editor.chmod(0o755) + environment["EDITOR"] = str(editor) + environment["PCD_TEST_MARKER"] = str(marker) + master_fd, slave_fd = pty.openpty() + + try: + result = subprocess.run( + [ + "bash", + "-c", + 'eval "$("$1" shell print bash)"; pcd config edit', + "pcd-test", + str(_pcd_executable()), + ], + env=environment, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + check=False, + timeout=10, + ) + finally: + os.close(slave_fd) + os.close(master_fd) + + assert result.returncode == 0 + assert marker.is_file() diff --git a/tests/test_catalog.py b/tests/integration/test_catalog.py similarity index 74% rename from tests/test_catalog.py rename to tests/integration/test_catalog.py index 78e8b54..5985da2 100644 --- a/tests/test_catalog.py +++ b/tests/integration/test_catalog.py @@ -2,12 +2,16 @@ from typing import TYPE_CHECKING +from pcd_cli.cache import ProjectCache +from pcd_cli.config import Config from pcd_cli.filesystem import canonical_path from pcd_cli.models import Project, ProjectSource if TYPE_CHECKING: from pathlib import Path + import pytest + from pcd_cli.catalog import ProjectCatalog @@ -34,6 +38,10 @@ def test_projects_build_cache_when_missing(projects: ProjectCatalog, tmp_path: P assert projects.cache.load() == items +def test_completion_candidates_are_empty_without_cache(projects: ProjectCatalog) -> None: + assert list(projects.completion_candidates("repo")) == [] + + def test_matches_refreshes_after_cache_miss(projects: ProjectCatalog, tmp_path: Path) -> None: root = tmp_path / "root" root.mkdir() @@ -120,6 +128,20 @@ def test_fuzzy_match_refreshes_after_cache_miss(projects: ProjectCatalog, tmp_pa assert [item.name for item in projects.search("mdc")] == ["medcab"] +def test_search_recovers_when_cache_disappears( + projects: ProjectCatalog, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "root" + (root / "repo/.git").mkdir(parents=True) + assert projects.config.add_root(root) + projects.cache.save(()) + monkeypatch.setattr(ProjectCache, "load", lambda _cache: None) + + assert [item.name for item in projects.search("repo")] == ["repo"] + + def test_parent_root_prefers_nearest_root(projects: ProjectCatalog, tmp_path: Path) -> None: outer = tmp_path / "outer" inner = outer / "inner" @@ -133,6 +155,20 @@ def test_parent_root_prefers_nearest_root(projects: ProjectCatalog, tmp_path: Pa assert projects.find_parent_root(tmp_path / "elsewhere") is None +def test_parent_root_ignores_shallower_match_after_nearest( + projects: ProjectCatalog, + tmp_path: Path, +) -> None: + outer = tmp_path / "outer" + inner = outer / "inner" + child = inner / "child" + child.mkdir(parents=True) + assert projects.config.add_root(inner) + assert projects.config.add_root(outer) + + assert projects.find_parent_root(child) == inner + + def test_exact_duplicates_use_history(projects: ProjectCatalog, tmp_path: Path) -> None: first = tmp_path / "first" second = tmp_path / "second" @@ -146,3 +182,19 @@ def test_exact_duplicates_use_history(projects: ProjectCatalog, tmp_path: Path) projects.history.record(items[1].path) assert projects.search("same") == [items[1], items[0]] + + +def test_manual_add_handles_concurrent_config_change( + projects: ProjectCatalog, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "repo" + project = Project("repo", canonical_path(path), path, ProjectSource.MANUAL) + monkeypatch.setattr(Config, "add_project", lambda _config, _project: False) + + assert projects.add_project(project) is False + + +def test_remove_missing_project_returns_false(projects: ProjectCatalog, tmp_path: Path) -> None: + assert projects.remove_project(tmp_path / "missing") is False diff --git a/tests/test_cli.py b/tests/integration/test_cli.py similarity index 73% rename from tests/test_cli.py rename to tests/integration/test_cli.py index e76ecc4..7dfbf0a 100644 --- a/tests/test_cli.py +++ b/tests/integration/test_cli.py @@ -5,8 +5,10 @@ import click import pytest +from click.shell_completion import CompletionItem import pcd_cli.cli as cli_module +import pcd_cli.cli.app as app_module from pcd_cli.catalog import ProjectCatalog from pcd_cli.cli import cli, ProjectCommandGroup from pcd_cli.config import InvalidConfigError @@ -92,3 +94,36 @@ def test_group_keeps_known_command() -> None: assert name == "init" assert command is not None assert rest == [] + + +def test_group_delegates_option_resolution() -> None: + group = ProjectCommandGroup() + ctx = click.Context(group) + + with pytest.raises(click.UsageError, match="No such option"): + group.resolve_command(ctx, ["--unknown"]) + + +def test_group_delegates_unknown_name_without_project_command() -> None: + group = ProjectCommandGroup() + ctx = click.Context(group) + + with pytest.raises(click.UsageError, match="No such command"): + group.resolve_command(ctx, ["repo"]) + + +def test_group_completion_deduplicates_command_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + group = ProjectCommandGroup() + group.add_command(click.Command("init")) + ctx = click.Context(group) + monkeypatch.setattr( + app_module, + "project_completions", + lambda _value: [CompletionItem("init")], + ) + + completions = group.shell_complete(ctx, "i") + + assert [item.value for item in completions] == ["init"] diff --git a/tests/test_cli_commands.py b/tests/integration/test_cli_commands.py similarity index 84% rename from tests/test_cli_commands.py rename to tests/integration/test_cli_commands.py index 6ac13c7..08032ce 100644 --- a/tests/test_cli_commands.py +++ b/tests/integration/test_cli_commands.py @@ -323,6 +323,69 @@ def test_config_edit_requires_configured_editor( assert "$VISUAL or $EDITOR" in result.output +def test_config_edit_falls_back_to_environment_for_invalid_config( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = ProjectCatalog.create().config + config.path.parent.mkdir(parents=True) + config.path.write_text("roots = [", encoding="utf-8") + monkeypatch.setenv("EDITOR", "nano") + calls: list[list[str]] = [] + + def run_editor(args: list[str], *, check: bool) -> subprocess.CompletedProcess[str]: + calls.append(args) + return subprocess.CompletedProcess(args, 0) + + monkeypatch.setattr("pcd_cli.cli.config.subprocess.run", run_editor) + + result = runner.invoke(cli, ["config", "edit"]) + + assert result.exit_code == 0 + assert calls == [["nano", str(config.path)]] + + +def test_config_edit_rejects_invalid_editor_command( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("EDITOR", "'") + + result = runner.invoke(cli, ["config", "edit"]) + + assert result.exit_code == 2 + assert "Invalid editor command" in result.output + + +def test_config_edit_rejects_empty_editor_command( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("EDITOR", " ") + + result = runner.invoke(cli, ["config", "edit"]) + + assert result.exit_code == 2 + assert "$VISUAL or $EDITOR" in result.output + + +def test_config_edit_reports_editor_failure( + runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("EDITOR", "nano") + + def run_editor(args: list[str], *, check: bool) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args, 7) + + monkeypatch.setattr("pcd_cli.cli.config.subprocess.run", run_editor) + + result = runner.invoke(cli, ["config", "edit"]) + + assert result.exit_code == 1 + assert "Editor exited with status 7" in result.output + + def test_config_validate_reports_precise_error(runner: CliRunner) -> None: config = ProjectCatalog.create().config config.path.parent.mkdir(parents=True) diff --git a/tests/test_navigation.py b/tests/integration/test_navigation.py similarity index 91% rename from tests/test_navigation.py rename to tests/integration/test_navigation.py index 30a826f..f6e1b6e 100644 --- a/tests/test_navigation.py +++ b/tests/integration/test_navigation.py @@ -8,6 +8,8 @@ from pcd_cli.cli import cli from pcd_cli.filesystem import canonical_path from pcd_cli.models import Project, ProjectSource +from pcd_cli.navigation import select_project +from pcd_cli.picker import ProjectPicker if TYPE_CHECKING: import pytest @@ -85,6 +87,20 @@ def test_duplicate_name_uses_choice( assert result.output.splitlines()[0] == str(canonical_path(second)) +def test_select_project_opens_picker_for_multiple_matches( + projects: ProjectCatalog, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + matches = ( + Project("first", tmp_path / "first", tmp_path / "first", ProjectSource.MANUAL), + Project("second", tmp_path / "second", tmp_path / "second", ProjectSource.MANUAL), + ) + monkeypatch.setattr(ProjectPicker, "run", lambda _picker: matches[1]) + + assert select_project(projects, matches, "") is matches[1] + + def test_cancel_multiple_does_not_refresh( runner: CliRunner, tmp_path: Path, diff --git a/tests/test_shell_integration.py b/tests/integration/test_shell_integration.py similarity index 81% rename from tests/test_shell_integration.py rename to tests/integration/test_shell_integration.py index c43504d..0578ef9 100644 --- a/tests/test_shell_integration.py +++ b/tests/integration/test_shell_integration.py @@ -9,7 +9,9 @@ import pytest from pcd_cli.cli import cli +from pcd_cli.cli.shell import shell_init from pcd_cli.shell_integration import ( + inactive_shell_message, render_shell_integration, Shell, ShellIntegration, @@ -54,6 +56,13 @@ def test_legacy_shell_init_remains_available(runner: CliRunner) -> None: assert "zsh_source" in result.output +def test_legacy_shell_init_can_render_as_standalone_command(runner: CliRunner) -> None: + result = runner.invoke(shell_init, ["bash"]) + + assert result.exit_code == 0 + assert "bash_source" in result.output + + @pytest.mark.parametrize("shell", list(Shell)) def test_shell_wrapper_uses_registered_root_commands( runner: CliRunner, @@ -69,6 +78,11 @@ def test_shell_wrapper_uses_registered_root_commands( assert "interactive" in result.output +def test_render_rejects_unsupported_shell() -> None: + with pytest.raises(ValueError, match="Unsupported shell"): + render_shell_integration("powershell") # type: ignore[arg-type] + + def test_shell_install_detects_zsh_and_is_idempotent( runner: CliRunner, monkeypatch: pytest.MonkeyPatch, @@ -140,6 +154,13 @@ def test_shell_uninstall_does_not_remove_manual_configuration( assert config.read_text(encoding="utf-8") == manual +def test_shell_uninstall_reports_absent_integration(runner: CliRunner) -> None: + result = runner.invoke(cli, ["shell", "uninstall", "bash"]) + + assert result.exit_code == 0 + assert "not installed" in result.output + + def test_shell_status_reports_configuration_and_activation( runner: CliRunner, monkeypatch: pytest.MonkeyPatch, @@ -215,6 +236,45 @@ def test_invalid_managed_block_is_rejected(monkeypatch: pytest.MonkeyPatch) -> N ShellIntegration.detect().state() +def test_reversed_managed_block_is_rejected() -> None: + integration = ShellIntegration.for_shell(Shell.BASH) + integration.config_path.write_text( + "# <<< pcd shell integration <<<\n# >>> pcd shell integration >>>\n", + encoding="utf-8", + ) + + with pytest.raises(ShellIntegrationError, match="invalid pcd-managed"): + integration.state() + + +def test_shell_config_rejects_invalid_utf8() -> None: + integration = ShellIntegration.for_shell(Shell.BASH) + integration.config_path.write_bytes(b"\xff") + + with pytest.raises(ShellIntegrationError, match="not valid UTF-8"): + integration.state() + + +def test_uninstall_handles_managed_block_at_end_of_file() -> None: + integration = ShellIntegration.for_shell(Shell.BASH) + integration.config_path.write_text( + "keep\n# >>> pcd shell integration >>>\npcd\n# <<< pcd shell integration <<<", + encoding="utf-8", + ) + + assert integration.uninstall() is True + assert integration.config_path.read_text(encoding="utf-8") == "keep\n" + + +def test_inactive_shell_message_handles_detection_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SHELL", "/bin/unsupported") + + message = inactive_shell_message() + + assert "Shell integration is not active" in message + assert "pcd shell install" in message + + def test_shell_integration_state_detects_manual_and_absent() -> None: integration = ShellIntegration.for_shell(Shell.BASH) assert integration.state() is ShellIntegrationState.ABSENT diff --git a/tests/test_cache.py b/tests/unit/test_cache.py similarity index 91% rename from tests/test_cache.py rename to tests/unit/test_cache.py index 500556a..9e45b39 100644 --- a/tests/test_cache.py +++ b/tests/unit/test_cache.py @@ -70,3 +70,10 @@ def test_cache_prefix_does_not_return_partial_data_from_corrupt_cache(tmp_path: ) assert cache.find_prefix("re") is None + + +def test_cache_ignores_invalid_utf8(tmp_path: Path) -> None: + cache = ProjectCache(tmp_path / "projects.jsonl") + cache.path.write_bytes(b"\xff") + + assert cache.load() is None diff --git a/tests/test_config.py b/tests/unit/test_config.py similarity index 91% rename from tests/test_config.py rename to tests/unit/test_config.py index 03b8660..00e70a7 100644 --- a/tests/test_config.py +++ b/tests/unit/test_config.py @@ -32,6 +32,13 @@ def test_reads_editor_setting(projects: ProjectCatalog) -> None: assert projects.config.load().editor == "nvim --clean" +def test_effective_config_includes_editor(projects: ProjectCatalog) -> None: + projects.config.path.parent.mkdir(parents=True) + projects.config.path.write_text('editor = "nvim --clean"\n', encoding="utf-8") + + assert 'editor = "nvim --clean"' in projects.config.effective_toml() + + def test_add_and_remove_root(projects: ProjectCatalog, tmp_path: Path) -> None: root = tmp_path / "projects" root.mkdir() @@ -164,6 +171,14 @@ def test_unknown_home_directory_is_invalid_config(projects: ProjectCatalog) -> N projects.config.load() +def test_invalid_utf8_config(projects: ProjectCatalog) -> None: + projects.config.path.parent.mkdir(parents=True) + projects.config.path.write_bytes(b"\xff") + + with pytest.raises(InvalidConfigError, match="not valid UTF-8"): + projects.config.load() + + @pytest.mark.parametrize( "text, key", [ diff --git a/tests/test_filesystem.py b/tests/unit/test_filesystem.py similarity index 100% rename from tests/test_filesystem.py rename to tests/unit/test_filesystem.py diff --git a/tests/test_history.py b/tests/unit/test_history.py similarity index 89% rename from tests/test_history.py rename to tests/unit/test_history.py index f71bd3f..1fe17e3 100644 --- a/tests/test_history.py +++ b/tests/unit/test_history.py @@ -56,6 +56,15 @@ def test_history_is_bounded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> assert set(history.load()) == {Path("/two"), Path("/three")} +@pytest.mark.parametrize("limit", [0, -1]) +def test_non_positive_history_limit_discards_entries(tmp_path: Path, limit: int) -> None: + history = UsageHistory(tmp_path / "history.json", limit=limit) + + history.record(Path("/repo")) + + assert history.load() == {} + + def test_history_trims_existing_data_to_new_limit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_picker.py b/tests/unit/test_picker.py similarity index 73% rename from tests/test_picker.py rename to tests/unit/test_picker.py index e561922..a257b3b 100644 --- a/tests/test_picker.py +++ b/tests/unit/test_picker.py @@ -1,5 +1,7 @@ from pathlib import Path +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document from prompt_toolkit.input.defaults import create_pipe_input from prompt_toolkit.output import DummyOutput @@ -74,6 +76,30 @@ def test_render_paginates_long_list() -> None: assert any("16/20" in fragment[1] for fragment in lines) +def test_render_uses_first_full_page() -> None: + projects = tuple(project(f"project-{index}") for index in range(20)) + picker = ProjectPicker(projects, {}) + picker.matches = list(projects) + picker.selected_index = 2 + + output = "".join(fragment[1] for fragment in picker._render()) + + assert "project-0" in output + assert "project-11" in output + assert "project-12" not in output + + +def test_filter_without_running_application() -> None: + projects = (project("alpha"), project("beta")) + picker = ProjectPicker(projects, {}) + picker.matches = list(projects) + + picker._filter(Buffer(document=Document("beta"))) + + assert picker.matches == [projects[1]] + assert picker.selected_index == 0 + + def test_render_empty_results() -> None: picker = ProjectPicker((), {}) diff --git a/tests/test_project_list.py b/tests/unit/test_project_list.py similarity index 100% rename from tests/test_project_list.py rename to tests/unit/test_project_list.py diff --git a/tests/test_scanner.py b/tests/unit/test_scanner.py similarity index 97% rename from tests/test_scanner.py rename to tests/unit/test_scanner.py index 65c2b87..061a36e 100644 --- a/tests/test_scanner.py +++ b/tests/unit/test_scanner.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from pcd_cli.models import Project, ProjectSettings -from pcd_cli.scanner import ProjectScanner +from pcd_cli.scanner import _mark_visited, ProjectScanner if TYPE_CHECKING: from collections.abc import Iterator @@ -191,6 +191,10 @@ def broken(path: str | os.PathLike[str]) -> Iterator[os.DirEntry[str]]: assert scan(settings(root)) == [] +def test_disappeared_directory_is_not_marked_visited(tmp_path: Path) -> None: + assert _mark_visited(tmp_path / "missing", set()) is False + + def test_unrelated_roots_are_both_scanned(tmp_path: Path) -> None: first = tmp_path / "first" second = tmp_path / "second" diff --git a/tests/test_search.py b/tests/unit/test_search.py similarity index 100% rename from tests/test_search.py rename to tests/unit/test_search.py