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
14 changes: 13 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ scripts.nullforge = "nullforge.cli.__main__:main"

[dependency-groups]
dev = [ "poethepoet", "prek", "ruff", "ty" ]
tests = [ "pytest", "pytest-cov", "pytest-sugar" ]

[tool.hatch]
version.path = "nullforge/__init__.py"
Expand All @@ -32,14 +33,25 @@ lint.ignore = [
"N818", # Domain errors are named by failure mode, not with an -Error suffix
"S108", # Probable insecure usage of temporary file or directory
]
lint.per-file-ignores."tests/*" = [
"S101", # Use of `assert` detected
"S104", # Possible binding to all interfaces
"S105", # Hardcoded password string
"S106", # Hardcoded password argument
]
lint.isort.lines-after-imports = 2

[tool.ty]
environment.root = [ "./nullforge" ]

[tool.pytest]
ini_options.testpaths = [ "tests" ]
ini_options.addopts = "-ra -q --tb=short --cov=nullforge --strict-markers"

[tool.poe]
tasks.format = "uv run ruff format ."
tasks.check-format = "uv run ruff format --check ."
tasks.lint = "uv run ruff check ."
tasks.typecheck = "uv run --group dev ty check"
tasks.typecheck = "uv run --group dev --group tests ty check"
tasks.check = [ "check-format", "lint", "typecheck" ]
tasks.tests = "uv run --group tests pytest"
Empty file added tests/__init__.py
Empty file.
Empty file added tests/cli/__init__.py
Empty file.
Empty file.
Empty file.
77 changes: 77 additions & 0 deletions tests/cli/components/completion/test_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from nullforge.cli.app import NullForgeCli, cli
from nullforge.cli.components.completion.errors import ProfileNotFound, UnsupportedShell
from nullforge.cli.core.errors import Unreachable


@pytest.fixture
def exposed_app(app: NullForgeCli) -> NullForgeCli:
app.cli_root = cli
return app


@pytest.fixture
def home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(Path, "home", lambda: tmp_path)
return tmp_path


class TestScript:
def test_powershell_script(self, exposed_app: NullForgeCli) -> None:
script = exposed_app.completion.script("powershell")

assert "Register-ArgumentCompleter" in script
assert "_NULLFORGE_COMPLETE" in script

def test_zsh_script_uses_click_template(self, exposed_app: NullForgeCli) -> None:
script = exposed_app.completion.script("zsh")

assert "_NULLFORGE_COMPLETE" in script
assert "nullforge" in script

def test_unknown_shell_raises(self, exposed_app: NullForgeCli) -> None:
with pytest.raises(UnsupportedShell):
exposed_app.completion.script("klingon")

def test_without_exposed_cli_raises(self, app: NullForgeCli) -> None:
with pytest.raises(Unreachable):
app.completion.script("powershell")


class TestInstall:
def test_powershell_install_is_idempotent(self, exposed_app: NullForgeCli, home: Path, tmp_path: Path) -> None:
profile = tmp_path / "profile" / "Microsoft.PowerShell_profile.ps1"
run_result = MagicMock(returncode=0, stdout=f"{profile}\n")
target = "nullforge.cli.components.completion.controller"

with (
patch(f"{target}.shutil.which", return_value="C:\\pwsh.exe"),
patch(f"{target}.subprocess.run", return_value=run_result),
):
script_path, profile_path = exposed_app.completion.install("powershell")
exposed_app.completion.install("powershell")

assert script_path == home / ".nullforge" / "completion.ps1"
assert "Register-ArgumentCompleter" in script_path.read_text(encoding="utf-8")
assert profile_path == profile
content = profile.read_text(encoding="utf-8")
assert content.count("# >>> nullforge completion >>>") == 1, "install must be idempotent"
assert f'. "{script_path}"' in content

def test_fish_install_writes_autoload_file(self, exposed_app: NullForgeCli, home: Path) -> None:
script_path, profile_path = exposed_app.completion.install("fish")

assert script_path == home / ".config" / "fish" / "completions" / "nullforge.fish"
assert profile_path == script_path
assert script_path.is_file()

def test_powershell_missing_shell_raises(self, exposed_app: NullForgeCli, home: Path) -> None:
target = "nullforge.cli.components.completion.controller"

with patch(f"{target}.shutil.which", return_value=None):
with pytest.raises(ProfileNotFound):
exposed_app.completion.install("powershell")
97 changes: 97 additions & 0 deletions tests/cli/components/completion/test_powershell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import click
import pytest
from click.shell_completion import CompletionItem, get_completion_class

from nullforge.cli.components.completion.powershell import (
PowerShellComplete,
register_powershell_completion,
split_powershell_line,
)


def _completer() -> PowerShellComplete:
return PowerShellComplete(click.Group("nullforge"), {}, "nullforge", "_NULLFORGE_COMPLETE")


class TestSplitPowershellLine:
def test_plain_words(self) -> None:
assert split_powershell_line("nullforge cast -r warp") == ["nullforge", "cast", "-r", "warp"]

def test_backslash_paths_survive(self) -> None:
assert split_powershell_line(r"nullforge cast -r D:\runes\cu") == [
"nullforge",
"cast",
"-r",
r"D:\runes\cu",
]

def test_double_quoted_word_with_spaces(self) -> None:
assert split_powershell_line('nullforge cast -r "D:\\x y\\r.py"') == [
"nullforge",
"cast",
"-r",
"D:\\x y\\r.py",
]

def test_single_quoted_word(self) -> None:
assert split_powershell_line("nullforge cast -i 'inv file.py'") == [
"nullforge",
"cast",
"-i",
"inv file.py",
]

def test_unterminated_quote(self) -> None:
assert split_powershell_line('nullforge "abc') == ["nullforge", "abc"]


class TestGetCompletionArgs:
def test_mid_word(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", "nullforge cast -r wa")
monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", "wa")

assert _completer().get_completion_args() == (["cast", "-r"], "wa")

def test_trailing_space(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", "nullforge cast -r")
monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", "")

assert _completer().get_completion_args() == (["cast", "-r"], "")

def test_windows_path_incomplete_unmangled(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("_NULLFORGE_COMPLETE_WORDS", r"nullforge cast -r D:\runes\cu")
monkeypatch.setenv("_NULLFORGE_COMPLETE_INCOMPLETE", r"D:\runes\cu")

assert _completer().get_completion_args() == (["cast", "-r"], r"D:\runes\cu")

def test_missing_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("_NULLFORGE_COMPLETE_WORDS", raising=False)
monkeypatch.delenv("_NULLFORGE_COMPLETE_INCOMPLETE", raising=False)

assert _completer().get_completion_args() == ([], "")


class TestFormatCompletion:
def test_with_help(self) -> None:
item = CompletionItem("warp", help="Cloudflare WARP deployment module.")
assert _completer().format_completion(item) == "plain\twarp\tCloudflare WARP deployment module."

def test_without_help(self) -> None:
assert _completer().format_completion(CompletionItem("warp")) == "plain\twarp\t"


class TestSource:
def test_script_registers_both_command_names(self) -> None:
script = _completer().source()

assert "Register-ArgumentCompleter -Native" in script
assert '"nullforge", "nullforge.exe"' in script
assert "_NULLFORGE_COMPLETE_WORDS" in script
assert "_NULLFORGE_COMPLETE_INCOMPLETE" in script


def test_registered_under_both_names() -> None:
register_powershell_completion()

assert get_completion_class("powershell") is PowerShellComplete
assert get_completion_class("pwsh") is PowerShellComplete
Empty file.
141 changes: 141 additions & 0 deletions tests/cli/components/foundry/test_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import json
import sys
from unittest.mock import MagicMock, patch

import pytest

from nullforge.cli.app import NullForgeCli
from nullforge.cli.components.foundry.controller import CastOptions
from nullforge.cli.components.foundry.errors import PyinfraExecutionFailed, PyinfraLaunchFailed
from nullforge.foundry import FOUNDRY_DIR
from nullforge.runes import rune_path


RUN_TARGET = "nullforge.cli.components.foundry.controller.subprocess.run"


class TestResolveStages:
def test_no_runes_uses_full_cast(self, app: NullForgeCli) -> None:
assert app.foundry.resolve_stages((), False) == [(FOUNDRY_DIR / "full_cast.py", [])]

def test_with_prepare_without_runes_bootstraps_full_cast(self, app: NullForgeCli) -> None:
stages = app.foundry.resolve_stages((), True)

assert stages == [
(FOUNDRY_DIR / "cast.py", [rune_path("prepare")]),
(FOUNDRY_DIR / "full_cast.py", []),
]

def test_selected_runes_dedupe_in_order(self, app: NullForgeCli) -> None:
warp, dns = rune_path("warp"), rune_path("dns")

stages = app.foundry.resolve_stages((warp, dns, warp), False)

assert stages == [(FOUNDRY_DIR / "cast.py", [warp, dns])]

def test_with_prepare_becomes_separate_first_stage(self, app: NullForgeCli) -> None:
warp = rune_path("warp")

stages = app.foundry.resolve_stages((warp,), True)

assert stages == [
(FOUNDRY_DIR / "cast.py", [rune_path("prepare")]),
(FOUNDRY_DIR / "cast.py", [warp]),
]

def test_with_prepare_does_not_duplicate_explicit_prepare(self, app: NullForgeCli) -> None:
prepare = rune_path("prepare")

stages = app.foundry.resolve_stages((prepare,), True)

assert stages == [(FOUNDRY_DIR / "cast.py", [prepare])]


class TestBuildArgv:
def test_full_cast_argv(self, app: NullForgeCli) -> None:
argv = app.foundry.build_argv("inv.py", FOUNDRY_DIR / "full_cast.py", [], CastOptions())

assert argv == [
sys.executable,
"-m",
"nullforge.foundry._pyinfra",
"inv.py",
str(FOUNDRY_DIR / "full_cast.py"),
]

def test_all_option_kinds(self, app: NullForgeCli) -> None:
warp = rune_path("warp")
options = CastOptions(
dry=True,
verbosity=2,
ssh_user="root",
ssh_port=2222,
limit=("a", "b"),
data=("k=v",),
extra=("--serial",),
)

argv = app.foundry.build_argv("@local", FOUNDRY_DIR / "cast.py", [warp], options)

assert argv == [
sys.executable,
"-m",
"nullforge.foundry._pyinfra",
"@local",
str(FOUNDRY_DIR / "cast.py"),
"--dry",
"-v",
"-v",
"--ssh-user",
"root",
"--ssh-port",
"2222",
"--limit",
"a",
"--limit",
"b",
"--data",
"k=v",
"--data",
f"_nullforge_runes={json.dumps([str(warp)])}",
"--serial",
]


class TestCast:
def test_runs_pyinfra_inheriting_stdio(self, app: NullForgeCli) -> None:
with patch(RUN_TARGET, return_value=MagicMock(returncode=0)) as run_mock:
app.foundry.cast("@local", (rune_path("base"),), False, CastOptions())

assert run_mock.call_count == 1
assert run_mock.call_args.kwargs == {"check": False}, "stdio must be inherited, not captured"

def test_nonzero_exit_raises_with_returncode(self, app: NullForgeCli) -> None:
with patch(RUN_TARGET, return_value=MagicMock(returncode=3)):
with pytest.raises(PyinfraExecutionFailed) as e:
app.foundry.cast("@local", (), False, CastOptions())

assert e.value.returncode == 3

def test_launch_failure_raises(self, app: NullForgeCli) -> None:
with patch(RUN_TARGET, side_effect=OSError("no python")):
with pytest.raises(PyinfraLaunchFailed):
app.foundry.cast("@local", (), False, CastOptions())

def test_with_prepare_full_cast_runs_prepare_then_full_cast(self, app: NullForgeCli) -> None:
with patch(RUN_TARGET, return_value=MagicMock(returncode=0)) as run_mock:
app.foundry.cast("@local", (), True, CastOptions())

assert run_mock.call_count == 2
first_argv = run_mock.call_args_list[0].args[0]
second_argv = run_mock.call_args_list[1].args[0]
assert str(FOUNDRY_DIR / "cast.py") in first_argv
assert f"_nullforge_runes={json.dumps([str(rune_path('prepare'))])}" in first_argv
assert str(FOUNDRY_DIR / "full_cast.py") in second_argv

def test_failed_prepare_stage_stops_the_cast(self, app: NullForgeCli) -> None:
with patch(RUN_TARGET, return_value=MagicMock(returncode=1)) as run_mock:
with pytest.raises(PyinfraExecutionFailed):
app.foundry.cast("@local", (rune_path("warp"),), True, CastOptions())

assert run_mock.call_count == 1, "the second stage must not run after a failed prepare"
20 changes: 20 additions & 0 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from collections.abc import Iterator

import pytest

from nullforge.cli.app import NullForgeCli
from nullforge.cli.core import BaseApplication


@pytest.fixture(autouse=True)
def _reset_singleton() -> Iterator[None]:
BaseApplication.reset()
NullForgeCli.reset()
yield
BaseApplication.reset()
NullForgeCli.reset()


@pytest.fixture
def app() -> NullForgeCli:
return NullForgeCli()
Empty file added tests/cli/core/__init__.py
Empty file.
Loading
Loading