Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
typecheck \
quality \
test \
test-unit \
test-integration \
test-e2e \
cov \
check \
bench \
Expand Down Expand Up @@ -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 \
Expand Down
4 changes: 2 additions & 2 deletions src/pcd_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`")
Expand Down
125 changes: 125 additions & 0 deletions tests/e2e/test_cli_workflow.py
Original file line number Diff line number Diff line change
@@ -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()
52 changes: 52 additions & 0 deletions tests/test_catalog.py → tests/integration/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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()
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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
35 changes: 35 additions & 0 deletions tests/test_cli.py → tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading