DEVX-63: feat: extract generic tools into devx, expand devx.mak, remove personal references
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 52s
Post-merge / vikunja (push) Successful in 49s
Post-merge / release (push) Successful in 59s
Post-merge / badges (push) Successful in 1m0s
Post-merge / configure-repo (push) Successful in 49s

This commit was merged in pull request #103.
This commit is contained in:
2026-06-26 18:48:43 +00:00
parent 85e38f37fd
commit 58261f7d1a
16 changed files with 2791 additions and 49 deletions
+222
View File
@@ -0,0 +1,222 @@
"""Unit tests for devx.tools.check_agent_docs."""
import re
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_agent_docs import (
DEFAULT_REPO_PATH_PREFIXES,
DEFAULT_SCAN_DIRS,
DEFAULT_SCAN_EXTENSIONS,
DEFAULT_SCAN_FILES,
MIN_PATH_REF_LENGTH_DEFAULT,
_check_file,
_collect_doc_files,
_is_legitimate_ref,
_should_skip,
cli,
)
class TestShouldSkip:
def test_skips_excluded_path(self, tmp_path: Path) -> None:
f = tmp_path / "docs" / "retrospectives" / "r.md"
f.parent.mkdir(parents=True)
f.write_text("")
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is True
def test_does_not_skip_normal(self, tmp_path: Path) -> None:
f = tmp_path / "docs" / "guide.md"
f.parent.mkdir(parents=True)
f.write_text("")
assert _should_skip(f, ["docs/retrospectives"], tmp_path) is False
def test_returns_false_for_path_outside_repo(self, tmp_path: Path) -> None:
f = Path("/tmp/some_other_path/guide.md")
assert _should_skip(f, [], tmp_path) is False
class TestIsLegitimateRef:
def test_legitimate_legacy(self) -> None:
assert _is_legitimate_ref("This is legacy code", ["legacy"]) is True
def test_not_legitimate(self) -> None:
assert _is_legitimate_ref("Use this file", ["legacy"]) is False
def test_case_insensitive(self) -> None:
assert _is_legitimate_ref("This is LEGACY", ["legacy"]) is True
class TestCollectDocFiles:
def test_collects_devin_and_docs(self, tmp_path: Path) -> None:
(tmp_path / ".devin").mkdir()
(tmp_path / ".devin" / "guide.md").write_text("")
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "api.md").write_text("")
(tmp_path / "README.md").write_text("")
files = _collect_doc_files(tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
names = {f.name for f in files}
assert "guide.md" in names
assert "api.md" in names
assert "README.md" in names
def test_excludes_paths(self, tmp_path: Path) -> None:
(tmp_path / "docs" / "retrospectives").mkdir(parents=True)
(tmp_path / "docs" / "retrospectives" / "r.md").write_text("")
(tmp_path / "docs" / "guide.md").write_text("")
files = _collect_doc_files(
tmp_path, DEFAULT_SCAN_DIRS, DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, ["docs/retrospectives"]
)
names = {f.name for f in files}
assert "guide.md" in names
assert "r.md" not in names
def test_deduplicates(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "api.md").write_text("")
files = _collect_doc_files(tmp_path, ["docs", "docs"], DEFAULT_SCAN_FILES, DEFAULT_SCAN_EXTENSIONS, [])
assert len(files) == 1
class TestCheckFile:
def test_detects_deleted_file_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/old.py for details.\n")
issues = _check_file(
doc, tmp_path, {"scripts/old.py"}, [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
)
assert any("deleted file" in i for i in issues)
def test_detects_nonexistent_file_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/nonexistent.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
assert any("non-existent file" in i for i in issues)
def test_does_not_flag_existing_file(self, tmp_path: Path) -> None:
(tmp_path / "scripts").mkdir()
(tmp_path / "scripts" / "exists.py").write_text("")
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See scripts/exists.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
assert issues == []
def test_detects_deprecated_pattern(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("Use ansible/envs/prod/secrets.yml for config.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
)
assert any("deprecated pattern" in i for i in issues)
def test_legitimate_ref_skips_deprecated(self, tmp_path: Path) -> None:
# Create the referenced file so the non-existent check doesn't trigger
secrets = tmp_path / "ansible" / "envs" / "prod" / "secrets.yml"
secrets.parent.mkdir(parents=True)
secrets.write_text("")
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("The legacy ansible/envs/prod/secrets.yml is deprecated.\n")
patterns = [re.compile(r"ansible/envs/[^/]+/secrets\.yml")]
issues = _check_file(
doc, tmp_path, set(), patterns, ["deprecated"], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT
)
assert issues == []
def test_unicode_error_returns_empty(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_bytes(b"\xff\xfe\x00\x00")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
assert issues == []
def test_skips_short_ref(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See a.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, 5)
# "a.py" is only 4 chars, below min_path_ref_length
assert issues == []
def test_skips_ref_without_repo_prefix(self, tmp_path: Path) -> None:
doc = tmp_path / "docs" / "guide.md"
doc.parent.mkdir(parents=True)
doc.write_text("See vendor/some/long/path.py for details.\n")
issues = _check_file(doc, tmp_path, set(), [], [], DEFAULT_REPO_PATH_PREFIXES, MIN_PATH_REF_LENGTH_DEFAULT)
# "vendor/" is not in repo_path_prefixes
assert issues == []
class TestCli:
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value={}),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_stale_ref(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("See scripts/deleted.py\n")
cfg = {"deleted_files": ["scripts/deleted.py"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_load_config_returns_empty_when_not_dict(self) -> None:
from devx.tools.check_agent_docs import _load_config
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": "not a dict"}):
assert _load_config() == {}
def test_load_config_returns_dict_when_valid(self) -> None:
from devx.tools.check_agent_docs import _load_config
cfg = {"scan_dirs": ["custom"]}
with patch("devx.tools.check_agent_docs._load_pyproject_devx", return_value={"check_agent_docs": cfg}):
assert _load_config() == cfg
def test_invalid_regex_pattern_skipped(self, tmp_path: Path) -> None:
(tmp_path / "docs").mkdir()
(tmp_path / "docs" / "guide.md").write_text("All good.\n")
cfg = {"deprecated_patterns": ["[invalid"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
def test_custom_scan_dirs(self, tmp_path: Path) -> None:
custom = tmp_path / "custom_docs"
custom.mkdir()
(custom / "guide.md").write_text("See scripts/deleted.py\n")
cfg = {"scan_dirs": ["custom_docs"], "deleted_files": ["scripts/deleted.py"]}
runner = CliRunner()
with (
patch("devx.tools.check_agent_docs._load_config", return_value=cfg),
patch("devx.tools.check_agent_docs.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
+292
View File
@@ -0,0 +1,292 @@
"""Unit tests for devx.ci.check_auto_merge_ready."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.ci.check_auto_merge_ready import (
cli,
get_pr_title_from_gitea,
get_vikunja_title_optional,
is_branch_behind_master,
)
class TestIsBranchBehindMaster:
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_when_ahead(self, mock_run: MagicMock) -> None:
# First: fetch (ok), second: ahead count (ok), third: behind count = 0
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=0, stdout="0\n", stderr=""),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_true_when_behind(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="0\n", stderr=""),
MagicMock(returncode=0, stdout="5\n", stderr=""),
]
assert is_branch_behind_master("feature") is True
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_git_error(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="error"),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_timeout(self, mock_run: MagicMock) -> None:
import subprocess
mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=30)
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_value_error(self, mock_run: MagicMock) -> None:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=0, stdout="not_a_number\n", stderr=""),
]
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_on_file_not_found(self, mock_run: MagicMock) -> None:
mock_run.side_effect = FileNotFoundError("git not found")
assert is_branch_behind_master("feature") is False
@patch("devx.ci.check_auto_merge_ready.subprocess.run")
def test_returns_false_when_behind_check_fails(self, mock_run: MagicMock) -> None:
# fetch ok, ahead count ok, behind count command fails
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="3\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="error"),
]
assert is_branch_behind_master("feature") is False
class TestGetPrTitleFromGitea:
def test_returns_none_without_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert get_pr_title_from_gitea("owner/repo", 1) is None
def test_returns_none_with_invalid_repo(self) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
assert get_pr_title_from_gitea("invalid", 1) is None
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_fetches_title(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1)
assert result == "DEVX-1: Fix bug"
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.get_pr.side_effect = Exception("API error")
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1)
assert result is None
class TestGetVikunjaTitleOptional:
def test_returns_none_without_token(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert get_vikunja_title_optional("DEVX-1") is None
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_title_when_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-1", "title": "Fix bug"}]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result == "Fix bug"
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_none_when_not_found(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-2", "title": "Other task"}]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result is None
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_paginates_until_found(self, mock_client_cls: MagicMock) -> None:
from devx.config import DEFAULT_PER_PAGE
mock_client = MagicMock()
# First page: full page of non-matching tasks, second page: match
page1 = [{"identifier": f"DEVX-{i}", "title": f"Task {i}"} for i in range(DEFAULT_PER_PAGE)]
page2 = [{"identifier": "DEVX-99", "title": "Found it"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-99")
assert result == "Found it"
@patch("devx.ci.check_auto_merge_ready.VikunjaClient")
def test_returns_none_when_empty_first_page(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True):
result = get_vikunja_title_optional("DEVX-1")
assert result is None
class TestCli:
def test_fails_without_task_id(self) -> None:
runner = CliRunner()
with patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX"}, clear=True):
result = runner.invoke(cli, ["--branch", "no-task-id-here"])
assert result.exit_code != 0
def test_local_mode_no_pr_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo"])
assert result.exit_code == 0
assert "local mode" in result.output
def test_validates_pr_title_format(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "Bad title"])
assert result.exit_code != 0
assert "format" in result.output.lower() or "mismatch" in result.output.lower()
def test_passes_with_valid_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
):
result = runner.invoke(cli, ["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"])
assert result.exit_code == 0
assert "satisfied" in result.output
def test_skip_behind_check(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-behind-check"],
)
assert result.exit_code == 0
def test_fails_when_behind_master(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=True),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "behind" in result.output.lower()
def test_skip_vikunja(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo", "--skip-vikunja"],
)
assert result.exit_code == 0
def test_fetches_pr_title_from_gitea(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value="DEVX-1: Fix foo"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
)
assert result.exit_code == 0
assert "from Gitea" in result.output
def test_fails_when_pr_number_but_no_title(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": ""}, clear=True),
patch("devx.ci.check_auto_merge_ready.get_pr_title_from_gitea", return_value=None),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--repo", "owner/repo", "--pr-number", "1"],
)
assert result.exit_code != 0
assert "Could not fetch" in result.output
def test_fails_when_vikunja_token_set_but_task_not_found(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value=None),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "Could not find Vikunja task" in result.output
def test_passes_with_vikunja_title_match(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Fix foo"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code == 0
assert "Vikunja title match OK" in result.output
def test_fails_with_vikunja_title_mismatch(self) -> None:
runner = CliRunner()
with (
patch.dict("os.environ", {"DEVX_TASK_PREFIX": "DEVX", "VIKUNJA_TOKEN": "tok"}, clear=True),
patch("devx.ci.check_auto_merge_ready.is_branch_behind_master", return_value=False),
patch("devx.ci.check_auto_merge_ready.get_vikunja_title_optional", return_value="Different title"),
):
result = runner.invoke(
cli,
["--branch", "DEVX-1-fix-foo", "--pr-title", "DEVX-1: Fix foo"],
)
assert result.exit_code != 0
assert "does not match Vikunja" in result.output
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for devx.tools.check_mutable_globals."""
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_mutable_globals import (
DEFAULT_SCAN_DIRS,
DEFAULT_SKIP_DIRS,
_load_config,
_should_skip,
cli,
find_mutable_globals,
)
class TestFindMutableGlobals:
def test_detects_set_global_with_path_hint(self, tmp_path: Path) -> None:
source = "_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_SEEN" in issues[0]
assert "set()" in issues[0]
def test_detects_dict_global_with_path_hint(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_CACHE" in issues[0]
def test_detects_list_global_with_path_hint(self, tmp_path: Path) -> None:
source = "PATHS: list[Path] = []\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "PATHS" in issues[0]
def test_skips_non_mutable_globals(self, tmp_path: Path) -> None:
source = "_MAX: int = 10\n_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "_SEEN" in issues[0]
def test_skips_globals_without_path_hint(self, tmp_path: Path) -> None:
source = "_DATA: dict[str, int] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 0
def test_detects_path_type_annotation(self, tmp_path: Path) -> None:
source = "_FILES: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_known_safe_exception(self, tmp_path: Path) -> None:
source = "_SEEN: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
known_safe = {("mod.py", 1, "_SEEN")}
issues = find_mutable_globals(f, tmp_path, known_safe)
assert len(issues) == 0
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
f = tmp_path / "mod.py"
f.write_text("def broken(:\n")
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_detects_mutable_literal_dict(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any] = {}\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_detects_mutable_literal_list(self, tmp_path: Path) -> None:
source = "SEEN_PATHS: list[Path] = []\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_detects_mutable_literal_set(self, tmp_path: Path) -> None:
source = "REGISTRY: set[Path] = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
def test_skips_function_definitions(self, tmp_path: Path) -> None:
source = "def foo():\n pass\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_handles_assign_with_name_target(self, tmp_path: Path) -> None:
source = "SEEN_PATHS = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert len(issues) == 1
assert "SEEN_PATHS" in issues[0]
def test_skips_annotation_without_value(self, tmp_path: Path) -> None:
source = "_CACHE: dict[Path, Any]\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
assert issues == []
def test_skips_attribute_call(self, tmp_path: Path) -> None:
# collections.defaultdict is an Attribute call, not a Name call
source = "_CACHE: dict[Path, Any] = collections.defaultdict(list)\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
# Attribute calls are skipped (pass), so not flagged as mutable literal
assert issues == []
def test_multiple_assign_targets(self, tmp_path: Path) -> None:
source = "SEEN = CACHE = set()\n"
f = tmp_path / "mod.py"
f.write_text(source)
issues = find_mutable_globals(f, tmp_path, set())
# Both SEEN and CACHE should be flagged
assert len(issues) == 2
class TestShouldSkip:
def test_skips_pycache(self) -> None:
assert _should_skip(Path("/a/__pycache__/b.py"), DEFAULT_SKIP_DIRS) is True
def test_skips_venv(self) -> None:
assert _should_skip(Path("/a/.venv/b.py"), DEFAULT_SKIP_DIRS) is True
def test_does_not_skip_normal(self) -> None:
assert _should_skip(Path("/a/src/b.py"), DEFAULT_SKIP_DIRS) is False
class TestLoadConfig:
def test_defaults_when_no_pyproject(self, tmp_path: Path) -> None:
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value={}):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
assert skip_dirs == DEFAULT_SKIP_DIRS
assert known_safe == set()
def test_reads_config_from_pyproject(self) -> None:
cfg = {
"check_mutable_globals": {
"scan_dirs": ["src", "tests"],
"skip_dirs": ["__pycache__", ".tox"],
"known_safe": ["src/mod.py:10:_CACHE"],
}
}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == ["src", "tests"]
assert ".tox" in skip_dirs
assert ("src/mod.py", 10, "_CACHE") in known_safe
def test_returns_defaults_when_cfg_not_dict(self) -> None:
with patch(
"devx.tools.check_mutable_globals._load_pyproject_devx",
return_value={"check_mutable_globals": "not a dict"},
):
scan_dirs, skip_dirs, known_safe = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
assert skip_dirs == DEFAULT_SKIP_DIRS
assert known_safe == set()
def test_known_safe_with_invalid_line_number(self) -> None:
cfg = {"check_mutable_globals": {"known_safe": ["mod.py:abc:_CACHE"]}}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
_, _, known_safe = _load_config()
assert known_safe == set()
def test_scan_dirs_not_list_returns_default(self) -> None:
cfg = {"check_mutable_globals": {"scan_dirs": "not a list"}}
with patch("devx.tools.check_mutable_globals._load_pyproject_devx", return_value=cfg):
scan_dirs, _, _ = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
class TestCli:
def test_passes_when_no_issues(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["empty_dir"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_issues_found(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "src"
scan_dir.mkdir()
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_scan_dir_option_overrides_config(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "custom"
scan_dir.mkdir()
(scan_dir / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["other"], set(), set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, ["--scan-dir", "custom"])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_skips_files_in_skip_dirs(self, tmp_path: Path) -> None:
scan_dir = tmp_path / "src"
pycache = scan_dir / "__pycache__"
pycache.mkdir(parents=True)
(pycache / "mod.py").write_text("_SEEN: set[Path] = set()\n")
runner = CliRunner()
with (
patch("devx.tools.check_mutable_globals._load_config", return_value=(["src"], {"__pycache__"}, set())),
patch("devx.tools.check_mutable_globals.Path.cwd", return_value=tmp_path),
):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
+208
View File
@@ -0,0 +1,208 @@
"""Unit tests for devx.tools.check_pyproject_deps."""
from pathlib import Path
from click.testing import CliRunner
from devx.tools.check_pyproject_deps import check_deps, cli
class TestCheckDeps:
def test_no_issues_when_all_documented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
# CLI framework
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_finds_undocumented_dependency(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 1
assert "click" in issues[0]
def test_finds_multiple_undocumented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"requests>=2.0"
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 2
def test_handles_optional_dependencies(self, tmp_path: Path) -> None:
content = """\
[project.optional-dependencies]
ci = [
# Test runner
"pytest>=8",
"pytest-cov>=4",
]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert len(issues) == 1
assert "pytest-cov" in issues[0]
def test_returns_file_not_found_for_missing_file(self, tmp_path: Path) -> None:
issues = check_deps(tmp_path / "nonexistent.toml")
assert len(issues) == 1
assert "not found" in issues[0]
def test_empty_deps_section_no_issues(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_skips_non_deps_sections(self, tmp_path: Path) -> None:
content = """\
[project]
name = "test"
version = "0.1.0"
[project.dependencies]
# HTTP
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_handles_dash_prefixed_deps(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
-requests>=2.0
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_empty_lines_in_deps_section(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
# CLI
"click>=8.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
assert issues == []
def test_non_dep_non_comment_line_resets_prev(self, tmp_path: Path) -> None:
# A line that's not a comment, not a dep, not empty — resets prev_was_comment
content = """\
[project.dependencies]
# Comment
ci = [
"requests>=2.0",
]
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# "requests" is preceded by a comment, but the `ci = [` line resets prev_was_comment
# Actually `ci = [` doesn't start with - or ", so it hits the else branch
assert len(issues) == 1
def test_section_transition_exits_deps(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP
"requests>=2.0"
[project.optional-dependencies]
# Test runner
"pytest>=8"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# Both deps are documented
assert issues == []
def test_deps_after_other_section_not_checked(self, tmp_path: Path) -> None:
content = """\
[project]
name = "test"
[project.dependencies]
# Documented
"requests>=2.0"
[tool.ruff]
line-length = 120
"undocumented-dep>=1.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
issues = check_deps(f)
# The "undocumented-dep" is in [tool.ruff], not a deps section
assert issues == []
class TestCli:
def test_passes_when_all_documented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# HTTP client
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
runner = CliRunner()
with __import__("contextlib").chdir(tmp_path):
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_fails_when_undocumented(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
"requests>=2.0"
"""
f = tmp_path / "pyproject.toml"
f.write_text(content)
runner = CliRunner()
with __import__("contextlib").chdir(tmp_path):
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "FAILED" in result.output
def test_custom_file_option(self, tmp_path: Path) -> None:
content = """\
[project.dependencies]
# Documented
"requests>=2.0"
"""
f = tmp_path / "custom.toml"
f.write_text(content)
runner = CliRunner()
result = runner.invoke(cli, ["--file", str(f)])
assert result.exit_code == 0
+239
View File
@@ -0,0 +1,239 @@
"""Unit tests for devx.tools.check_test_coverage."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from devx.tools.check_test_coverage import (
BUILTIN_RULES,
DEFAULT_SKIP_EXTENSIONS,
DEFAULT_TEST_INDICATORS,
_changed_files,
_find_missing_tests,
_is_test_file,
_load_rules,
_resolve_test_path,
_should_skip_file,
main,
)
class TestIsTestFile:
def test_tests_dir(self) -> None:
assert _is_test_file("tests/unit/test_foo.py", DEFAULT_TEST_INDICATORS) is True
def test_test_prefix(self) -> None:
assert _is_test_file("src/test_foo.py", DEFAULT_TEST_INDICATORS) is True
def test_test_suffix(self) -> None:
assert _is_test_file("src/foo_test.py", DEFAULT_TEST_INDICATORS) is True
def test_non_test_file(self) -> None:
assert _is_test_file("src/foo.py", DEFAULT_TEST_INDICATORS) is False
class TestShouldSkipFile:
def test_skips_dotfiles(self) -> None:
assert _should_skip_file(".gitignore", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_markdown(self) -> None:
assert _should_skip_file("README.md", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_yaml(self) -> None:
assert _should_skip_file("config.yml", [], DEFAULT_SKIP_EXTENSIONS) is True
def test_does_not_skip_python(self) -> None:
assert _should_skip_file("src/foo.py", [], DEFAULT_SKIP_EXTENSIONS) is False
def test_skips_by_pattern(self) -> None:
assert _should_skip_file("src/__init__.py", ["__init__.py"], DEFAULT_SKIP_EXTENSIONS) is True
def test_skips_by_glob_pattern(self) -> None:
assert _should_skip_file("src/config.py", ["config.py"], DEFAULT_SKIP_EXTENSIONS) is True
class TestResolveTestPath:
def test_resolves_name(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/unit/test_{name}", "src/foo.py", tmp_path)
assert result == tmp_path / "tests" / "unit" / "test_foo"
def test_resolves_module(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/unit/test_{module}_{name}", "src/pkg/foo.py", tmp_path)
assert result == tmp_path / "tests" / "unit" / "test_pkg_foo"
def test_resolves_package_prefix(self, tmp_path: Path) -> None:
result = _resolve_test_path(
"tests/unit/test_{package_prefix}_{name}",
"scripts/utils/secrets.py",
tmp_path,
)
assert result == tmp_path / "tests" / "unit" / "test_utils_secrets"
def test_normalizes_hyphens(self, tmp_path: Path) -> None:
result = _resolve_test_path("tests/test_{name}", "scripts/my-script.py", tmp_path)
assert result == tmp_path / "tests" / "test_my_script"
class TestFindMissingTests:
def test_finds_missing_test(self, tmp_path: Path) -> None:
files = ["scripts/foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert "scripts/foo.py" in missing
def test_no_missing_when_test_exists(self, tmp_path: Path) -> None:
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
files = ["scripts/foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_skips_test_files(self, tmp_path: Path) -> None:
files = ["tests/unit/test_foo.py"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_skips_non_python_files(self, tmp_path: Path) -> None:
files = ["README.md", "config.yml"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
def test_no_rule_no_requirement(self, tmp_path: Path) -> None:
files = ["unknown_type.xyz"]
rules = BUILTIN_RULES
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
assert missing == {}
class TestChangedFiles:
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_staged_only(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(stdout="file1.py\nfile2.py\n", returncode=0)
files = _changed_files(staged_only=True, repo_root=tmp_path)
assert files == ["file1.py", "file2.py"]
cmd = mock_run.call_args.args[0]
assert "--cached" in cmd
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(stdout="file1.py\n", returncode=0)
files = _changed_files(staged_only=False, repo_root=tmp_path)
assert files == ["file1.py"]
cmd = mock_run.call_args.args[0]
assert "origin/master...HEAD" in cmd
@patch("devx.tools.check_test_coverage.subprocess.run")
def test_fallback_to_staged(self, mock_run: MagicMock, tmp_path: Path) -> None:
# First call fails, second succeeds
mock_run.side_effect = [
MagicMock(stdout="", returncode=1),
MagicMock(stdout="file1.py\n", returncode=0),
]
files = _changed_files(staged_only=False, repo_root=tmp_path)
assert files == ["file1.py"]
class TestLoadRules:
def test_defaults_when_no_config(self) -> None:
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value={}):
rules, skip, indicators, skip_ext = _load_rules()
assert rules == BUILTIN_RULES
assert skip == []
assert indicators == DEFAULT_TEST_INDICATORS
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
def test_custom_rules(self) -> None:
cfg = {
"check_test_coverage": {
"rules": [
{
"source_pattern": "lib/*.py",
"test_paths": ["tests/test_{name}"],
"description": "Missing: tests/test_{name}",
}
],
"skip_patterns": ["__init__.py"],
}
}
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
rules, skip, indicators, skip_ext = _load_rules()
assert len(rules) == 1
assert rules[0]["source_pattern"] == "lib/*.py"
assert "__init__.py" in skip
def test_returns_defaults_when_cfg_not_dict(self) -> None:
with patch(
"devx.tools.check_test_coverage._load_pyproject_devx", return_value={"check_test_coverage": "not a dict"}
):
rules, skip, indicators, skip_ext = _load_rules()
assert rules == BUILTIN_RULES
assert skip == []
def test_skip_extensions_not_list_returns_default(self) -> None:
cfg = {"check_test_coverage": {"skip_extensions": "not a list"}}
with patch("devx.tools.check_test_coverage._load_pyproject_devx", return_value=cfg):
_, _, _, skip_ext = _load_rules()
assert skip_ext == DEFAULT_SKIP_EXTENSIONS
def test_test_paths_not_list_skips_rule(self, tmp_path: Path) -> None:
files = ["scripts/foo.py"]
rules = [
{
"source_pattern": "scripts/*.py",
"test_paths": "not a list",
"description": "Missing test",
}
]
missing = _find_missing_tests(files, tmp_path, rules, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS)
# Rule matches but test_paths is not a list, so it's skipped — no missing
assert missing == {}
class TestMain:
def test_no_changed_files(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=[]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 0
def test_all_have_tests(self, tmp_path: Path) -> None:
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "scripts" / "tests" / "test_foo.py").write_text("")
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 0
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main([]) == 1
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
with (
patch("devx.tools.check_test_coverage._changed_files", return_value=["scripts/foo.py"]),
patch(
"devx.tools.check_test_coverage._load_rules",
return_value=(BUILTIN_RULES, [], DEFAULT_TEST_INDICATORS, DEFAULT_SKIP_EXTENSIONS),
),
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
):
assert main(["--warn-only"]) == 0
+2 -2
View File
@@ -356,6 +356,6 @@ class TestListBranches:
class TestWhoami:
def test_whoami(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "emil"
assert cli.whoami() == "testuser"