flow
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_action.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_action.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_action.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_action.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_bootstrap.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_bootstrap.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_bootstrap.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_bootstrap.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_cli.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_cli.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_commands.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_commands.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_commands.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_commands.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_completion.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_completion.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_completion.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_completion.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_config.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_config.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_console.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_console.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_console.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_console.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_dotfiles.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_dotfiles.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_dotfiles.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_dotfiles.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_dotfiles_folding.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_dotfiles_folding.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_paths.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_paths.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_paths.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_paths.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_platform.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_platform.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_platform.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_platform.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_self_hosting.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_self_hosting.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_self_hosting.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_self_hosting.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_stow.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_stow.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_stow.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_stow.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_variables.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_variables.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_variables.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_variables.cpython-313.pyc
Normal file
Binary file not shown.
115
tests/test_action.py
Normal file
115
tests/test_action.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Tests for flow.core.action."""
|
||||
|
||||
from flow.core.action import Action, ActionExecutor
|
||||
from flow.core.console import ConsoleLogger
|
||||
|
||||
|
||||
def test_action_defaults():
|
||||
a = Action(type="test", description="Test action")
|
||||
assert a.status == "pending"
|
||||
assert a.error is None
|
||||
assert a.skip_on_error is True
|
||||
assert a.os_filter is None
|
||||
assert a.data == {}
|
||||
|
||||
|
||||
def test_executor_register_and_execute(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
results = []
|
||||
|
||||
def handler(data):
|
||||
results.append(data["key"])
|
||||
|
||||
executor.register("test-action", handler)
|
||||
|
||||
actions = [
|
||||
Action(type="test-action", description="Do thing", data={"key": "value1"}),
|
||||
Action(type="test-action", description="Do another", data={"key": "value2"}),
|
||||
]
|
||||
|
||||
executor.execute(actions, current_os="linux")
|
||||
assert results == ["value1", "value2"]
|
||||
assert actions[0].status == "completed"
|
||||
assert actions[1].status == "completed"
|
||||
|
||||
|
||||
def test_executor_dry_run(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
executed = []
|
||||
|
||||
executor.register("test", lambda data: executed.append(1))
|
||||
|
||||
actions = [Action(type="test", description="Should not run")]
|
||||
executor.execute(actions, dry_run=True)
|
||||
assert executed == [] # Nothing executed
|
||||
out = capsys.readouterr().out
|
||||
assert "EXECUTION PLAN" in out
|
||||
|
||||
|
||||
def test_executor_skip_on_error(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
|
||||
def failing_handler(data):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
executor.register("fail", failing_handler)
|
||||
|
||||
actions = [
|
||||
Action(type="fail", description="Will fail", skip_on_error=True),
|
||||
Action(type="fail", description="Should still run", skip_on_error=True),
|
||||
]
|
||||
|
||||
executor.execute(actions, current_os="linux")
|
||||
assert actions[0].status == "skipped"
|
||||
assert actions[1].status == "skipped"
|
||||
|
||||
|
||||
def test_executor_critical_failure_stops(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
|
||||
def failing_handler(data):
|
||||
raise RuntimeError("critical failure")
|
||||
|
||||
executor.register("fail", failing_handler)
|
||||
executor.register("ok", lambda data: None)
|
||||
|
||||
actions = [
|
||||
Action(type="fail", description="Critical", skip_on_error=False),
|
||||
Action(type="ok", description="Should not run"),
|
||||
]
|
||||
|
||||
executor.execute(actions, current_os="linux")
|
||||
assert actions[0].status == "failed"
|
||||
assert actions[1].status == "pending" # Never reached
|
||||
|
||||
|
||||
def test_executor_os_filter(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
executed = []
|
||||
|
||||
executor.register("test", lambda data: executed.append(data.get("name")))
|
||||
|
||||
actions = [
|
||||
Action(type="test", description="Linux only", data={"name": "linux"}, os_filter="linux"),
|
||||
Action(type="test", description="macOS only", data={"name": "macos"}, os_filter="macos"),
|
||||
Action(type="test", description="Any OS", data={"name": "any"}),
|
||||
]
|
||||
|
||||
executor.execute(actions, current_os="linux")
|
||||
assert "linux" in executed
|
||||
assert "any" in executed
|
||||
assert "macos" not in executed
|
||||
|
||||
|
||||
def test_executor_no_handler(capsys):
|
||||
console = ConsoleLogger()
|
||||
executor = ActionExecutor(console)
|
||||
|
||||
actions = [Action(type="unknown", description="No handler registered")]
|
||||
executor.execute(actions, current_os="linux")
|
||||
assert actions[0].status == "skipped"
|
||||
129
tests/test_bootstrap.py
Normal file
129
tests/test_bootstrap.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for flow.commands.bootstrap — action planning."""
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.commands.bootstrap import _get_profiles, _plan_actions
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.platform import PlatformInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
return FlowContext(
|
||||
config=AppConfig(),
|
||||
manifest={
|
||||
"binaries": {
|
||||
"neovim": {
|
||||
"version": "0.10.4",
|
||||
"source": "github:neovim/neovim",
|
||||
"asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz",
|
||||
"platform-map": {"linux-arm64": {"os": "linux", "arch": "arm64"}},
|
||||
"install-script": "echo install",
|
||||
},
|
||||
},
|
||||
},
|
||||
platform=PlatformInfo(os="linux", arch="arm64", platform="linux-arm64"),
|
||||
console=ConsoleLogger(),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_empty_profile(ctx):
|
||||
actions = _plan_actions(ctx, "test", {}, {})
|
||||
assert actions == []
|
||||
|
||||
|
||||
def test_plan_hostname(ctx):
|
||||
actions = _plan_actions(ctx, "test", {"hostname": "myhost"}, {})
|
||||
types = [a.type for a in actions]
|
||||
assert "set-hostname" in types
|
||||
|
||||
|
||||
def test_plan_locale_and_shell(ctx):
|
||||
actions = _plan_actions(ctx, "test", {"locale": "en_US.UTF-8", "shell": "zsh"}, {})
|
||||
types = [a.type for a in actions]
|
||||
assert "set-locale" in types
|
||||
assert "set-shell" in types
|
||||
|
||||
|
||||
def test_plan_packages(ctx):
|
||||
env_config = {
|
||||
"packages": {
|
||||
"standard": ["git", "zsh", "tmux"],
|
||||
"binary": ["neovim"],
|
||||
},
|
||||
}
|
||||
actions = _plan_actions(ctx, "test", env_config, {})
|
||||
types = [a.type for a in actions]
|
||||
assert "pm-update" in types
|
||||
assert "install-packages" in types
|
||||
assert "install-binary" in types
|
||||
|
||||
|
||||
def test_plan_ssh_keygen(ctx):
|
||||
env_config = {
|
||||
"ssh_keygen": [
|
||||
{"type": "ed25519", "comment": "test@host", "filename": "id_ed25519"},
|
||||
],
|
||||
}
|
||||
actions = _plan_actions(ctx, "test", env_config, {})
|
||||
types = [a.type for a in actions]
|
||||
assert "generate-ssh-key" in types
|
||||
|
||||
|
||||
def test_plan_runcmd(ctx):
|
||||
env_config = {"runcmd": ["echo hello", "mkdir -p ~/tmp"]}
|
||||
actions = _plan_actions(ctx, "test", env_config, {})
|
||||
run_cmds = [a for a in actions if a.type == "run-command"]
|
||||
assert len(run_cmds) == 2
|
||||
|
||||
|
||||
def test_plan_requires(ctx):
|
||||
env_config = {"requires": ["VAR1", "VAR2"]}
|
||||
actions = _plan_actions(ctx, "test", env_config, {})
|
||||
checks = [a for a in actions if a.type == "check-variable"]
|
||||
assert len(checks) == 2
|
||||
assert all(not a.skip_on_error for a in checks)
|
||||
|
||||
|
||||
def test_plan_full_profile(ctx):
|
||||
"""Test planning with a realistic linux-vm profile."""
|
||||
env_config = {
|
||||
"requires": ["TARGET_HOSTNAME"],
|
||||
"os": "linux",
|
||||
"hostname": "$TARGET_HOSTNAME",
|
||||
"shell": "zsh",
|
||||
"locale": "en_US.UTF-8",
|
||||
"packages": {
|
||||
"standard": ["zsh", "tmux", "git"],
|
||||
"binary": ["neovim"],
|
||||
},
|
||||
"ssh_keygen": [{"type": "ed25519", "comment": "test"}],
|
||||
"configs": ["bin"],
|
||||
"runcmd": ["mkdir -p ~/projects"],
|
||||
}
|
||||
actions = _plan_actions(ctx, "linux-vm", env_config, {"TARGET_HOSTNAME": "myvm"})
|
||||
assert len(actions) >= 8
|
||||
|
||||
types = [a.type for a in actions]
|
||||
assert "check-variable" in types
|
||||
assert "set-hostname" in types
|
||||
assert "set-locale" in types
|
||||
assert "set-shell" in types
|
||||
assert "pm-update" in types
|
||||
assert "install-packages" in types
|
||||
assert "install-binary" in types
|
||||
assert "generate-ssh-key" in types
|
||||
assert "link-config" in types
|
||||
assert "run-command" in types
|
||||
|
||||
|
||||
def test_get_profiles_from_manifest(ctx):
|
||||
ctx.manifest = {"profiles": {"linux": {"os": "linux"}}}
|
||||
assert "linux" in _get_profiles(ctx)
|
||||
|
||||
|
||||
def test_get_profiles_rejects_environments(ctx):
|
||||
ctx.manifest = {"environments": {"legacy": {"os": "linux"}}}
|
||||
with pytest.raises(RuntimeError, match="no longer supported"):
|
||||
_get_profiles(ctx)
|
||||
153
tests/test_cli.py
Normal file
153
tests/test_cli.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for CLI routing and command registration."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _clean_env():
|
||||
"""Return env dict without DF_* variables that trigger enter's guard."""
|
||||
return {k: v for k, v in os.environ.items() if not k.startswith("DF_")}
|
||||
|
||||
|
||||
def test_version():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "--version"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "0.1.0" in result.stdout
|
||||
|
||||
|
||||
def test_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "enter" in result.stdout
|
||||
assert "dev" in result.stdout
|
||||
assert "dotfiles" in result.stdout
|
||||
assert "bootstrap" in result.stdout
|
||||
assert "package" in result.stdout
|
||||
assert "sync" in result.stdout
|
||||
assert "completion" in result.stdout
|
||||
|
||||
|
||||
def test_enter_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "target" in result.stdout
|
||||
assert "--dry-run" in result.stdout
|
||||
|
||||
|
||||
def test_dotfiles_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dotfiles", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "init" in result.stdout
|
||||
assert "link" in result.stdout
|
||||
assert "unlink" in result.stdout
|
||||
assert "status" in result.stdout
|
||||
assert "sync" in result.stdout
|
||||
|
||||
|
||||
def test_bootstrap_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "bootstrap", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "run" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "show" in result.stdout
|
||||
|
||||
|
||||
def test_package_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "package", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "install" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "remove" in result.stdout
|
||||
|
||||
|
||||
def test_sync_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "sync", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "check" in result.stdout
|
||||
assert "fetch" in result.stdout
|
||||
assert "summary" in result.stdout
|
||||
|
||||
|
||||
def test_dev_help():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dev", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "create" in result.stdout
|
||||
assert "exec" in result.stdout
|
||||
assert "connect" in result.stdout
|
||||
assert "list" in result.stdout
|
||||
assert "stop" in result.stdout
|
||||
assert "remove" in result.stdout
|
||||
assert "respawn" in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "ssh" in result.stdout
|
||||
assert "personal.orb" in result.stdout
|
||||
assert "tmux" in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run_no_tmux():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "--no-tmux", "personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "ssh" in result.stdout
|
||||
assert "tmux" not in result.stdout
|
||||
|
||||
|
||||
def test_enter_dry_run_with_user():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "enter", "--dry-run", "root@personal@orb"],
|
||||
capture_output=True, text=True, env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "root@personal.orb" in result.stdout
|
||||
|
||||
|
||||
def test_aliases():
|
||||
"""Test that command aliases work."""
|
||||
for alias, cmd in [("dot", "dotfiles"), ("pkg", "package"), ("setup", "bootstrap")]:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", alias, "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0, f"Alias '{alias}' failed"
|
||||
|
||||
|
||||
def test_dev_remove_alias():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "flow", "dev", "rm", "--help"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
59
tests/test_commands.py
Normal file
59
tests/test_commands.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Tests for command modules — registration and target parsing."""
|
||||
|
||||
from flow.commands.enter import _parse_target
|
||||
from flow.commands.container import _cname, _parse_image_ref
|
||||
|
||||
|
||||
class TestParseTarget:
|
||||
def test_full_target(self):
|
||||
user, ns, plat = _parse_target("root@personal@orb")
|
||||
assert user == "root"
|
||||
assert ns == "personal"
|
||||
assert plat == "orb"
|
||||
|
||||
def test_no_user(self):
|
||||
user, ns, plat = _parse_target("personal@orb")
|
||||
assert user is None
|
||||
assert ns == "personal"
|
||||
assert plat == "orb"
|
||||
|
||||
def test_namespace_only(self):
|
||||
user, ns, plat = _parse_target("personal")
|
||||
assert user is None
|
||||
assert ns == "personal"
|
||||
assert plat is None
|
||||
|
||||
|
||||
class TestCname:
|
||||
def test_adds_prefix(self):
|
||||
assert _cname("api") == "dev-api"
|
||||
|
||||
def test_no_double_prefix(self):
|
||||
assert _cname("dev-api") == "dev-api"
|
||||
|
||||
|
||||
class TestParseImageRef:
|
||||
def test_simple_image(self):
|
||||
ref, repo, tag, label = _parse_image_ref("node")
|
||||
assert ref == "registry.tomastm.com/node:latest"
|
||||
assert tag == "latest"
|
||||
|
||||
def test_tm0_shorthand(self):
|
||||
ref, repo, tag, label = _parse_image_ref("tm0/node")
|
||||
assert "registry.tomastm.com" in ref
|
||||
assert "node" in ref
|
||||
|
||||
def test_docker_shorthand(self):
|
||||
ref, repo, tag, label = _parse_image_ref("docker/python")
|
||||
assert "docker.io" in ref
|
||||
assert "python" in ref
|
||||
|
||||
def test_with_tag(self):
|
||||
ref, repo, tag, label = _parse_image_ref("node:20")
|
||||
assert tag == "20"
|
||||
assert ":20" in ref
|
||||
|
||||
def test_full_registry(self):
|
||||
ref, repo, tag, label = _parse_image_ref("ghcr.io/user/image:v1")
|
||||
assert ref == "ghcr.io/user/image:v1"
|
||||
assert tag == "v1"
|
||||
63
tests/test_completion.py
Normal file
63
tests/test_completion.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests for flow.commands.completion dynamic suggestions."""
|
||||
|
||||
from flow.commands import completion
|
||||
|
||||
|
||||
def test_complete_top_level_prefix():
|
||||
out = completion.complete(["flow", "do"], 2)
|
||||
assert "dotfiles" in out
|
||||
assert "dot" in out
|
||||
|
||||
|
||||
def test_complete_bootstrap_profiles(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_bootstrap_profiles", lambda: ["linux-vm", "macos-host"])
|
||||
out = completion.complete(["flow", "bootstrap", "show", "li"], 4)
|
||||
assert out == ["linux-vm"]
|
||||
|
||||
|
||||
def test_complete_package_install(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_manifest_packages", lambda: ["neovim", "fzf"])
|
||||
out = completion.complete(["flow", "package", "install", "n"], 4)
|
||||
assert out == ["neovim"]
|
||||
|
||||
|
||||
def test_complete_package_remove(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_installed_packages", lambda: ["hello", "jq"])
|
||||
out = completion.complete(["flow", "package", "remove", "h"], 4)
|
||||
assert out == ["hello"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_profile_value(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_dotfiles_profiles", lambda: ["work", "personal"])
|
||||
out = completion.complete(["flow", "dotfiles", "link", "--profile", "w"], 5)
|
||||
assert out == ["work"]
|
||||
|
||||
|
||||
def test_complete_enter_targets(monkeypatch):
|
||||
monkeypatch.setattr(completion, "_list_targets", lambda: ["personal@orb", "work@ec2"])
|
||||
out = completion.complete(["flow", "enter", "p"], 3)
|
||||
assert out == ["personal@orb"]
|
||||
|
||||
|
||||
def test_complete_dev_subcommands():
|
||||
out = completion.complete(["flow", "dev", "c"], 3)
|
||||
assert out == ["connect", "create"]
|
||||
|
||||
|
||||
def test_complete_completion_subcommands():
|
||||
out = completion.complete(["flow", "completion", "i"], 3)
|
||||
assert out == ["install-zsh"]
|
||||
|
||||
|
||||
def test_rc_snippet_is_idempotent(tmp_path):
|
||||
rc_path = tmp_path / ".zshrc"
|
||||
completion_dir = tmp_path / "completions"
|
||||
|
||||
first = completion._ensure_rc_snippet(rc_path, completion_dir)
|
||||
second = completion._ensure_rc_snippet(rc_path, completion_dir)
|
||||
|
||||
assert first is True
|
||||
assert second is False
|
||||
text = rc_path.read_text()
|
||||
assert text.count(completion.ZSH_RC_START) == 1
|
||||
assert text.count(completion.ZSH_RC_END) == 1
|
||||
70
tests/test_config.py
Normal file
70
tests/test_config.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for flow.core.config."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flow.core.config import AppConfig, FlowContext, load_config, load_manifest
|
||||
|
||||
|
||||
def test_load_config_missing_file(tmp_path):
|
||||
cfg = load_config(tmp_path / "nonexistent")
|
||||
assert isinstance(cfg, AppConfig)
|
||||
assert cfg.dotfiles_url == ""
|
||||
assert cfg.container_registry == "registry.tomastm.com"
|
||||
|
||||
|
||||
def test_load_config_ini(tmp_path):
|
||||
config_file = tmp_path / "config"
|
||||
config_file.write_text("""
|
||||
[repository]
|
||||
dotfiles_url=git@github.com:user/dots.git
|
||||
dotfiles_branch=dev
|
||||
|
||||
[paths]
|
||||
projects_dir=~/code
|
||||
|
||||
[defaults]
|
||||
container_registry=my.registry.com
|
||||
container_tag=v1
|
||||
tmux_session=main
|
||||
|
||||
[targets]
|
||||
personal=orb personal@orb
|
||||
work=ec2 work.ec2.internal ~/.ssh/id_work
|
||||
""")
|
||||
cfg = load_config(config_file)
|
||||
assert cfg.dotfiles_url == "git@github.com:user/dots.git"
|
||||
assert cfg.dotfiles_branch == "dev"
|
||||
assert cfg.projects_dir == "~/code"
|
||||
assert cfg.container_registry == "my.registry.com"
|
||||
assert cfg.container_tag == "v1"
|
||||
assert cfg.tmux_session == "main"
|
||||
assert len(cfg.targets) == 2
|
||||
assert cfg.targets[0].namespace == "personal"
|
||||
assert cfg.targets[0].platform == "orb"
|
||||
assert cfg.targets[0].ssh_host == "personal@orb"
|
||||
assert cfg.targets[1].ssh_identity == "~/.ssh/id_work"
|
||||
|
||||
|
||||
def test_load_manifest_missing_file(tmp_path):
|
||||
result = load_manifest(tmp_path / "nonexistent.yaml")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_load_manifest_valid(tmp_path):
|
||||
manifest = tmp_path / "manifest.yaml"
|
||||
manifest.write_text("""
|
||||
profiles:
|
||||
linux-vm:
|
||||
os: linux
|
||||
hostname: test
|
||||
""")
|
||||
result = load_manifest(manifest)
|
||||
assert "profiles" in result
|
||||
assert result["profiles"]["linux-vm"]["os"] == "linux"
|
||||
|
||||
|
||||
def test_load_manifest_non_dict(tmp_path):
|
||||
manifest = tmp_path / "manifest.yaml"
|
||||
manifest.write_text("- a\n- b\n")
|
||||
result = load_manifest(manifest)
|
||||
assert result == {}
|
||||
95
tests/test_console.py
Normal file
95
tests/test_console.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for flow.core.console."""
|
||||
|
||||
from flow.core.console import ConsoleLogger
|
||||
|
||||
|
||||
def test_console_info(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.info("hello")
|
||||
out = capsys.readouterr().out
|
||||
assert "[INFO]" in out
|
||||
assert "hello" in out
|
||||
|
||||
|
||||
def test_console_warn(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.warn("caution")
|
||||
out = capsys.readouterr().out
|
||||
assert "[WARN]" in out
|
||||
assert "caution" in out
|
||||
|
||||
|
||||
def test_console_error(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.error("bad thing")
|
||||
out = capsys.readouterr().out
|
||||
assert "[ERROR]" in out
|
||||
assert "bad thing" in out
|
||||
|
||||
|
||||
def test_console_success(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.success("done")
|
||||
out = capsys.readouterr().out
|
||||
assert "[SUCCESS]" in out
|
||||
assert "done" in out
|
||||
|
||||
|
||||
def test_console_step_lifecycle(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.step_start(1, 3, "Test step")
|
||||
c.step_command("echo hi")
|
||||
c.step_output("hi")
|
||||
c.step_complete("Done")
|
||||
out = capsys.readouterr().out
|
||||
assert "Step 1/3" in out
|
||||
assert "$ echo hi" in out
|
||||
assert "Done" in out
|
||||
|
||||
|
||||
def test_console_step_skip(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.start_time = 0
|
||||
c.step_skip("not needed")
|
||||
out = capsys.readouterr().out
|
||||
assert "Skipped" in out
|
||||
|
||||
|
||||
def test_console_step_fail(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.start_time = 0
|
||||
c.step_fail("exploded")
|
||||
out = capsys.readouterr().out
|
||||
assert "Failed" in out
|
||||
|
||||
|
||||
def test_console_table(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.table(["NAME", "VALUE"], [["foo", "bar"], ["baz", "qux"]])
|
||||
out = capsys.readouterr().out
|
||||
assert "NAME" in out
|
||||
assert "foo" in out
|
||||
assert "baz" in out
|
||||
|
||||
|
||||
def test_console_table_empty(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.table(["NAME"], [])
|
||||
out = capsys.readouterr().out
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_console_section_header(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.section_header("Test", "sub")
|
||||
out = capsys.readouterr().out
|
||||
assert "TEST" in out
|
||||
assert "sub" in out
|
||||
|
||||
|
||||
def test_console_plan_header(capsys):
|
||||
c = ConsoleLogger()
|
||||
c.plan_header("My Plan", 5)
|
||||
out = capsys.readouterr().out
|
||||
assert "MY PLAN" in out
|
||||
assert "5 actions" in out
|
||||
67
tests/test_dotfiles.py
Normal file
67
tests/test_dotfiles.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for flow.commands.dotfiles — link/unlink/status logic."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.commands.dotfiles import _discover_packages, _walk_package
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.platform import PlatformInfo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dotfiles_tree(tmp_path):
|
||||
"""Create a sample dotfiles directory structure."""
|
||||
common = tmp_path / "common"
|
||||
(common / "zsh").mkdir(parents=True)
|
||||
(common / "zsh" / ".zshrc").write_text("# zshrc")
|
||||
(common / "zsh" / ".zshenv").write_text("# zshenv")
|
||||
(common / "tmux").mkdir(parents=True)
|
||||
(common / "tmux" / ".tmux.conf").write_text("# tmux")
|
||||
|
||||
profiles = tmp_path / "profiles" / "work"
|
||||
(profiles / "git").mkdir(parents=True)
|
||||
(profiles / "git" / ".gitconfig").write_text("[user]\nname = Work")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_discover_packages_common(dotfiles_tree):
|
||||
packages = _discover_packages(dotfiles_tree)
|
||||
assert "zsh" in packages
|
||||
assert "tmux" in packages
|
||||
assert "git" not in packages # git is only in profiles
|
||||
|
||||
|
||||
def test_discover_packages_with_profile(dotfiles_tree):
|
||||
packages = _discover_packages(dotfiles_tree, profile="work")
|
||||
assert "zsh" in packages
|
||||
assert "tmux" in packages
|
||||
assert "git" in packages
|
||||
|
||||
|
||||
def test_discover_packages_profile_overrides(dotfiles_tree):
|
||||
# Add zsh to work profile
|
||||
work_zsh = dotfiles_tree / "profiles" / "work" / "zsh"
|
||||
work_zsh.mkdir(parents=True)
|
||||
(work_zsh / ".zshrc").write_text("# work zshrc")
|
||||
|
||||
packages = _discover_packages(dotfiles_tree, profile="work")
|
||||
# Profile should override common
|
||||
assert packages["zsh"] == work_zsh
|
||||
|
||||
|
||||
def test_walk_package(dotfiles_tree):
|
||||
home = Path("/tmp/fakehome")
|
||||
source = dotfiles_tree / "common" / "zsh"
|
||||
pairs = list(_walk_package(source, home))
|
||||
assert len(pairs) == 2
|
||||
sources = {str(s.name) for s, _ in pairs}
|
||||
assert ".zshrc" in sources
|
||||
assert ".zshenv" in sources
|
||||
targets = {str(t) for _, t in pairs}
|
||||
assert str(home / ".zshrc") in targets
|
||||
assert str(home / ".zshenv") in targets
|
||||
300
tests/test_dotfiles_folding.py
Normal file
300
tests/test_dotfiles_folding.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Integration tests for dotfiles tree folding behavior."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.commands.dotfiles import _discover_packages, _walk_package, run_link, run_status
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.paths import LINKED_STATE
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.stow import LinkTree, TreeFolder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
"""Create a mock FlowContext."""
|
||||
return FlowContext(
|
||||
config=AppConfig(),
|
||||
manifest={},
|
||||
platform=PlatformInfo(),
|
||||
console=ConsoleLogger(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dotfiles_with_nested(tmp_path):
|
||||
"""Create dotfiles with nested directory structure for folding tests."""
|
||||
common = tmp_path / "common"
|
||||
|
||||
# nvim package with nested config
|
||||
nvim = common / "nvim" / ".config" / "nvim"
|
||||
nvim.mkdir(parents=True)
|
||||
(nvim / "init.lua").write_text("-- init")
|
||||
(nvim / "lua").mkdir()
|
||||
(nvim / "lua" / "config.lua").write_text("-- config")
|
||||
(nvim / "lua" / "plugins.lua").write_text("-- plugins")
|
||||
|
||||
# zsh package with flat structure
|
||||
zsh = common / "zsh"
|
||||
zsh.mkdir(parents=True)
|
||||
(zsh / ".zshrc").write_text("# zshrc")
|
||||
(zsh / ".zshenv").write_text("# zshenv")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_dir(tmp_path):
|
||||
"""Create a temporary home directory."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
return home
|
||||
|
||||
|
||||
def test_tree_folding_single_package(dotfiles_with_nested, home_dir):
|
||||
"""Test that a single package can be folded into directory symlink."""
|
||||
# Discover nvim package
|
||||
packages = _discover_packages(dotfiles_with_nested)
|
||||
nvim_source = packages["nvim"]
|
||||
|
||||
# Build link tree
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
# Plan links for all nvim files
|
||||
operations = []
|
||||
for src, dst in _walk_package(nvim_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "nvim")
|
||||
operations.extend(ops)
|
||||
|
||||
# Execute operations
|
||||
folder.execute_operations(operations, dry_run=False)
|
||||
|
||||
# Check that we created efficient symlinks
|
||||
# In ideal case, we'd have one directory symlink instead of 3 file symlinks
|
||||
nvim_config = home_dir / ".config" / "nvim"
|
||||
|
||||
# Verify links work
|
||||
assert (nvim_config / "init.lua").exists()
|
||||
assert (nvim_config / "lua" / "config.lua").exists()
|
||||
|
||||
|
||||
def test_tree_unfolding_conflict(dotfiles_with_nested, home_dir):
|
||||
"""Test that tree unfolds when second package needs same directory."""
|
||||
common = dotfiles_with_nested / "common"
|
||||
|
||||
# Create second package that shares .config
|
||||
tmux = common / "tmux" / ".config" / "tmux"
|
||||
tmux.mkdir(parents=True)
|
||||
(tmux / "tmux.conf").write_text("# tmux")
|
||||
|
||||
# First, link nvim (can fold .config/nvim)
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
nvim_source = common / "nvim"
|
||||
for src, dst in _walk_package(nvim_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "nvim")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Now link tmux (should unfold if needed)
|
||||
tmux_source = common / "tmux"
|
||||
for src, dst in _walk_package(tmux_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "tmux")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Both packages should be linked
|
||||
assert (home_dir / ".config" / "nvim" / "init.lua").exists()
|
||||
assert (home_dir / ".config" / "tmux" / "tmux.conf").exists()
|
||||
|
||||
|
||||
def test_state_format_with_directory_links(dotfiles_with_nested, home_dir):
|
||||
"""Test that state file correctly tracks directory vs file links."""
|
||||
tree = LinkTree()
|
||||
|
||||
# Add a directory link
|
||||
tree.add_link(
|
||||
home_dir / ".config" / "nvim",
|
||||
dotfiles_with_nested / "common" / "nvim" / ".config" / "nvim",
|
||||
"nvim",
|
||||
is_dir_link=True,
|
||||
)
|
||||
|
||||
# Add a file link
|
||||
tree.add_link(
|
||||
home_dir / ".zshrc",
|
||||
dotfiles_with_nested / "common" / "zsh" / ".zshrc",
|
||||
"zsh",
|
||||
is_dir_link=False,
|
||||
)
|
||||
|
||||
# Convert to state
|
||||
state = tree.to_state()
|
||||
|
||||
# Verify format
|
||||
assert state["version"] == 2
|
||||
nvim_link = state["links"]["nvim"][str(home_dir / ".config" / "nvim")]
|
||||
assert nvim_link["is_directory_link"] is True
|
||||
|
||||
zsh_link = state["links"]["zsh"][str(home_dir / ".zshrc")]
|
||||
assert zsh_link["is_directory_link"] is False
|
||||
|
||||
|
||||
def test_state_backward_compatibility_rejected(home_dir):
|
||||
"""Old state format should be rejected (no backward compatibility)."""
|
||||
old_state = {
|
||||
"links": {
|
||||
"zsh": {
|
||||
str(home_dir / ".zshrc"): str(home_dir.parent / "dotfiles" / "zsh" / ".zshrc"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unsupported linked state format"):
|
||||
LinkTree.from_state(old_state)
|
||||
|
||||
|
||||
def test_discover_packages_with_flow_package(tmp_path):
|
||||
"""Test discovering the flow package itself from dotfiles."""
|
||||
common = tmp_path / "common"
|
||||
|
||||
# Create flow package
|
||||
flow_pkg = common / "flow" / ".config" / "flow"
|
||||
flow_pkg.mkdir(parents=True)
|
||||
(flow_pkg / "manifest.yaml").write_text("profiles: {}")
|
||||
(flow_pkg / "config").write_text("[repository]\n")
|
||||
|
||||
packages = _discover_packages(tmp_path)
|
||||
|
||||
# Flow package should be discovered like any other
|
||||
assert "flow" in packages
|
||||
assert packages["flow"] == common / "flow"
|
||||
|
||||
|
||||
def test_walk_flow_package(tmp_path):
|
||||
"""Test walking the flow package structure."""
|
||||
flow_pkg = tmp_path / "flow"
|
||||
flow_config = flow_pkg / ".config" / "flow"
|
||||
flow_config.mkdir(parents=True)
|
||||
(flow_config / "manifest.yaml").write_text("profiles: {}")
|
||||
(flow_config / "config").write_text("[repository]\n")
|
||||
|
||||
home = Path("/tmp/fakehome")
|
||||
pairs = list(_walk_package(flow_pkg, home))
|
||||
|
||||
# Should find both files
|
||||
assert len(pairs) == 2
|
||||
targets = [str(t) for _, t in pairs]
|
||||
assert str(home / ".config" / "flow" / "manifest.yaml") in targets
|
||||
assert str(home / ".config" / "flow" / "config") in targets
|
||||
|
||||
|
||||
def test_conflict_detection_before_execution(dotfiles_with_nested, home_dir):
|
||||
"""Test that conflicts are detected before any changes are made."""
|
||||
# Create existing file that conflicts
|
||||
existing = home_dir / ".zshrc"
|
||||
existing.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing.write_text("# existing zshrc")
|
||||
|
||||
# Try to link package that wants .zshrc
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
zsh_source = dotfiles_with_nested / "common" / "zsh"
|
||||
operations = []
|
||||
for src, dst in _walk_package(zsh_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "zsh")
|
||||
operations.extend(ops)
|
||||
|
||||
# Should detect conflict
|
||||
conflicts = folder.detect_conflicts(operations)
|
||||
assert len(conflicts) > 0
|
||||
assert any("already exists" in c for c in conflicts)
|
||||
|
||||
# Original file should be unchanged
|
||||
assert existing.read_text() == "# existing zshrc"
|
||||
|
||||
|
||||
def test_profile_switching_relink(tmp_path):
|
||||
"""Test switching between profiles maintains correct links."""
|
||||
# Create profiles
|
||||
common = tmp_path / "common"
|
||||
profiles = tmp_path / "profiles"
|
||||
|
||||
# Common zsh
|
||||
(common / "zsh").mkdir(parents=True)
|
||||
(common / "zsh" / ".zshrc").write_text("# common zsh")
|
||||
|
||||
# Work profile override
|
||||
(profiles / "work" / "zsh").mkdir(parents=True)
|
||||
(profiles / "work" / "zsh" / ".zshrc").write_text("# work zsh")
|
||||
|
||||
# Personal profile override
|
||||
(profiles / "personal" / "zsh").mkdir(parents=True)
|
||||
(profiles / "personal" / "zsh" / ".zshrc").write_text("# personal zsh")
|
||||
|
||||
# Test that profile discovery works correctly
|
||||
work_packages = _discover_packages(tmp_path, profile="work")
|
||||
personal_packages = _discover_packages(tmp_path, profile="personal")
|
||||
|
||||
# Both should find zsh, but from different sources
|
||||
assert "zsh" in work_packages
|
||||
assert "zsh" in personal_packages
|
||||
assert work_packages["zsh"] != personal_packages["zsh"]
|
||||
|
||||
|
||||
def test_can_fold_empty_directory():
|
||||
"""Test can_fold with empty directory."""
|
||||
tree = LinkTree()
|
||||
target_dir = Path("/home/user/.config/nvim")
|
||||
|
||||
# Empty directory - should be able to fold
|
||||
assert tree.can_fold(target_dir, "nvim")
|
||||
|
||||
|
||||
def test_can_fold_with_subdirectories():
|
||||
"""Test can_fold with nested directory structure."""
|
||||
tree = LinkTree()
|
||||
base = Path("/home/user/.config/nvim")
|
||||
|
||||
# Add nested files from same package
|
||||
tree.add_link(base / "init.lua", Path("/dotfiles/nvim/init.lua"), "nvim")
|
||||
tree.add_link(base / "lua" / "config.lua", Path("/dotfiles/nvim/lua/config.lua"), "nvim")
|
||||
tree.add_link(base / "lua" / "plugins" / "init.lua", Path("/dotfiles/nvim/lua/plugins/init.lua"), "nvim")
|
||||
|
||||
# Should be able to fold at base level
|
||||
assert tree.can_fold(base, "nvim")
|
||||
|
||||
# Add file from different package
|
||||
tree.add_link(base / "other.lua", Path("/dotfiles/other/other.lua"), "other")
|
||||
|
||||
# Now cannot fold
|
||||
assert not tree.can_fold(base, "nvim")
|
||||
|
||||
|
||||
def test_execute_operations_creates_parent_dirs(tmp_path):
|
||||
"""Test that execute_operations creates necessary parent directories."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = tmp_path / "dotfiles" / "nvim" / ".config" / "nvim" / "init.lua"
|
||||
target = tmp_path / "home" / ".config" / "nvim" / "init.lua"
|
||||
|
||||
# Create source
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("-- init")
|
||||
|
||||
# Target parent doesn't exist yet
|
||||
assert not target.parent.exists()
|
||||
|
||||
# Plan and execute
|
||||
ops = folder.plan_link(source, target, "nvim")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Parent should be created
|
||||
assert target.parent.exists()
|
||||
assert target.is_symlink()
|
||||
70
tests/test_paths.py
Normal file
70
tests/test_paths.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Tests for flow.core.paths."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flow.core.paths import (
|
||||
CONFIG_DIR,
|
||||
CONFIG_FILE,
|
||||
DATA_DIR,
|
||||
DOTFILES_DIR,
|
||||
INSTALLED_STATE,
|
||||
LINKED_STATE,
|
||||
MANIFEST_FILE,
|
||||
PACKAGES_DIR,
|
||||
SCRATCH_DIR,
|
||||
STATE_DIR,
|
||||
ensure_dirs,
|
||||
)
|
||||
|
||||
|
||||
def test_config_dir_under_home():
|
||||
assert ".config/devflow" in str(CONFIG_DIR)
|
||||
|
||||
|
||||
def test_data_dir_under_home():
|
||||
assert ".local/share/devflow" in str(DATA_DIR)
|
||||
|
||||
|
||||
def test_state_dir_under_home():
|
||||
assert ".local/state/devflow" in str(STATE_DIR)
|
||||
|
||||
|
||||
def test_manifest_file_in_config_dir():
|
||||
assert MANIFEST_FILE == CONFIG_DIR / "manifest.yaml"
|
||||
|
||||
|
||||
def test_config_file_in_config_dir():
|
||||
assert CONFIG_FILE == CONFIG_DIR / "config"
|
||||
|
||||
|
||||
def test_dotfiles_dir():
|
||||
assert DOTFILES_DIR == DATA_DIR / "dotfiles"
|
||||
|
||||
|
||||
def test_packages_dir():
|
||||
assert PACKAGES_DIR == DATA_DIR / "packages"
|
||||
|
||||
|
||||
def test_scratch_dir():
|
||||
assert SCRATCH_DIR == DATA_DIR / "scratch"
|
||||
|
||||
|
||||
def test_state_files():
|
||||
assert LINKED_STATE == STATE_DIR / "linked.json"
|
||||
assert INSTALLED_STATE == STATE_DIR / "installed.json"
|
||||
|
||||
|
||||
def test_ensure_dirs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("flow.core.paths.CONFIG_DIR", tmp_path / "config")
|
||||
monkeypatch.setattr("flow.core.paths.DATA_DIR", tmp_path / "data")
|
||||
monkeypatch.setattr("flow.core.paths.STATE_DIR", tmp_path / "state")
|
||||
monkeypatch.setattr("flow.core.paths.PACKAGES_DIR", tmp_path / "data" / "packages")
|
||||
monkeypatch.setattr("flow.core.paths.SCRATCH_DIR", tmp_path / "data" / "scratch")
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
assert (tmp_path / "config").is_dir()
|
||||
assert (tmp_path / "data").is_dir()
|
||||
assert (tmp_path / "state").is_dir()
|
||||
assert (tmp_path / "data" / "packages").is_dir()
|
||||
assert (tmp_path / "data" / "scratch").is_dir()
|
||||
32
tests/test_platform.py
Normal file
32
tests/test_platform.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for flow.core.platform."""
|
||||
|
||||
import platform as _platform
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.platform import PlatformInfo, detect_container_runtime, detect_platform
|
||||
|
||||
|
||||
def test_detect_platform_returns_platforminfo():
|
||||
info = detect_platform()
|
||||
assert isinstance(info, PlatformInfo)
|
||||
assert info.os in ("linux", "macos")
|
||||
assert info.arch in ("amd64", "arm64")
|
||||
assert info.platform == f"{info.os}-{info.arch}"
|
||||
|
||||
|
||||
def test_detect_platform_unsupported_os(monkeypatch):
|
||||
monkeypatch.setattr(_platform, "system", lambda: "FreeBSD")
|
||||
with pytest.raises(RuntimeError, match="Unsupported operating system"):
|
||||
detect_platform()
|
||||
|
||||
|
||||
def test_detect_platform_unsupported_arch(monkeypatch):
|
||||
monkeypatch.setattr(_platform, "machine", lambda: "mips")
|
||||
with pytest.raises(RuntimeError, match="Unsupported architecture"):
|
||||
detect_platform()
|
||||
|
||||
|
||||
def test_detect_container_runtime_returns_string_or_none():
|
||||
result = detect_container_runtime()
|
||||
assert result is None or result in ("docker", "podman")
|
||||
215
tests/test_self_hosting.py
Normal file
215
tests/test_self_hosting.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Tests for self-hosting flow config from dotfiles repository."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from flow.core import paths as paths_module
|
||||
from flow.core.config import load_config, load_manifest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths(tmp_path, monkeypatch):
|
||||
"""Mock path constants for testing."""
|
||||
config_dir = tmp_path / "config"
|
||||
dotfiles_dir = tmp_path / "dotfiles"
|
||||
|
||||
config_dir.mkdir()
|
||||
dotfiles_dir.mkdir()
|
||||
|
||||
test_paths = {
|
||||
"config_dir": config_dir,
|
||||
"dotfiles_dir": dotfiles_dir,
|
||||
"local_config": config_dir / "config",
|
||||
"local_manifest": config_dir / "manifest.yaml",
|
||||
"dotfiles_config": dotfiles_dir / "flow" / ".config" / "flow" / "config",
|
||||
"dotfiles_manifest": dotfiles_dir / "flow" / ".config" / "flow" / "manifest.yaml",
|
||||
}
|
||||
|
||||
# Patch at the paths module level
|
||||
monkeypatch.setattr(paths_module, "CONFIG_FILE", test_paths["local_config"])
|
||||
monkeypatch.setattr(paths_module, "MANIFEST_FILE", test_paths["local_manifest"])
|
||||
monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", test_paths["dotfiles_config"])
|
||||
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", test_paths["dotfiles_manifest"])
|
||||
|
||||
return test_paths
|
||||
|
||||
|
||||
def test_load_manifest_priority_dotfiles_first(mock_paths):
|
||||
"""Test that dotfiles manifest takes priority over local."""
|
||||
# Create both manifests
|
||||
local_manifest = mock_paths["local_manifest"]
|
||||
dotfiles_manifest = mock_paths["dotfiles_manifest"]
|
||||
|
||||
local_manifest.write_text("profiles:\n local:\n os: linux")
|
||||
|
||||
dotfiles_manifest.parent.mkdir(parents=True)
|
||||
dotfiles_manifest.write_text("profiles:\n dotfiles:\n os: macos")
|
||||
|
||||
# Should load from dotfiles
|
||||
manifest = load_manifest()
|
||||
assert "dotfiles" in manifest.get("profiles", {})
|
||||
assert "local" not in manifest.get("profiles", {})
|
||||
|
||||
|
||||
def test_load_manifest_fallback_to_local(mock_paths):
|
||||
"""Test fallback to local manifest when dotfiles doesn't exist."""
|
||||
local_manifest = mock_paths["local_manifest"]
|
||||
local_manifest.write_text("profiles:\n local:\n os: linux")
|
||||
|
||||
# Dotfiles manifest doesn't exist
|
||||
manifest = load_manifest()
|
||||
assert "local" in manifest.get("profiles", {})
|
||||
|
||||
|
||||
def test_load_manifest_empty_when_none_exist(mock_paths):
|
||||
"""Test empty dict returned when no manifests exist."""
|
||||
manifest = load_manifest()
|
||||
assert manifest == {}
|
||||
|
||||
|
||||
def test_load_config_priority_dotfiles_first(mock_paths):
|
||||
"""Test that dotfiles config takes priority over local."""
|
||||
local_config = mock_paths["local_config"]
|
||||
dotfiles_config = mock_paths["dotfiles_config"]
|
||||
|
||||
# Create local config
|
||||
local_config.write_text(
|
||||
"[repository]\n"
|
||||
"dotfiles_url = https://github.com/user/dotfiles-local.git\n"
|
||||
)
|
||||
|
||||
# Create dotfiles config
|
||||
dotfiles_config.parent.mkdir(parents=True)
|
||||
dotfiles_config.write_text(
|
||||
"[repository]\n"
|
||||
"dotfiles_url = https://github.com/user/dotfiles-from-repo.git\n"
|
||||
)
|
||||
|
||||
# Should load from dotfiles
|
||||
config = load_config()
|
||||
assert "dotfiles-from-repo" in config.dotfiles_url
|
||||
|
||||
|
||||
def test_load_config_fallback_to_local(mock_paths):
|
||||
"""Test fallback to local config when dotfiles doesn't exist."""
|
||||
local_config = mock_paths["local_config"]
|
||||
local_config.write_text(
|
||||
"[repository]\n"
|
||||
"dotfiles_url = https://github.com/user/dotfiles-local.git\n"
|
||||
)
|
||||
|
||||
# Dotfiles config doesn't exist
|
||||
config = load_config()
|
||||
assert "dotfiles-local" in config.dotfiles_url
|
||||
|
||||
|
||||
def test_load_config_empty_when_none_exist(mock_paths):
|
||||
"""Test default config returned when no configs exist."""
|
||||
config = load_config()
|
||||
assert config.dotfiles_url == ""
|
||||
assert config.dotfiles_branch == "main"
|
||||
|
||||
|
||||
def test_self_hosting_workflow(tmp_path, monkeypatch):
|
||||
"""Test complete self-hosting workflow.
|
||||
|
||||
Simulates:
|
||||
1. User has dotfiles repo with flow config
|
||||
2. Flow links its own config from dotfiles
|
||||
3. Flow reads from self-hosted location
|
||||
"""
|
||||
# Setup paths
|
||||
home = tmp_path / "home"
|
||||
dotfiles = tmp_path / "dotfiles"
|
||||
home.mkdir()
|
||||
dotfiles.mkdir()
|
||||
|
||||
# Create flow package in dotfiles
|
||||
flow_pkg = dotfiles / "flow" / ".config" / "flow"
|
||||
flow_pkg.mkdir(parents=True)
|
||||
|
||||
# Create manifest in dotfiles
|
||||
manifest_content = {
|
||||
"profiles": {
|
||||
"test-env": {
|
||||
"os": "linux",
|
||||
"packages": {"standard": ["git", "vim"]},
|
||||
}
|
||||
}
|
||||
}
|
||||
(flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content))
|
||||
|
||||
# Create config in dotfiles
|
||||
(flow_pkg / "config").write_text(
|
||||
"[repository]\n"
|
||||
"dotfiles_url = https://github.com/user/dotfiles.git\n"
|
||||
)
|
||||
|
||||
# Mock paths to use our temp directories
|
||||
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml")
|
||||
monkeypatch.setattr(paths_module, "DOTFILES_CONFIG", flow_pkg / "config")
|
||||
monkeypatch.setattr(paths_module, "MANIFEST_FILE", home / ".config" / "devflow" / "manifest.yaml")
|
||||
monkeypatch.setattr(paths_module, "CONFIG_FILE", home / ".config" / "devflow" / "config")
|
||||
|
||||
# Load config and manifest - should come from dotfiles
|
||||
manifest = load_manifest()
|
||||
config = load_config()
|
||||
|
||||
assert "test-env" in manifest.get("profiles", {})
|
||||
assert "github.com/user/dotfiles.git" in config.dotfiles_url
|
||||
|
||||
|
||||
def test_manifest_cascade_with_symlink(tmp_path, monkeypatch):
|
||||
"""Test that loading works correctly when symlink is used."""
|
||||
# Setup
|
||||
dotfiles = tmp_path / "dotfiles"
|
||||
home_config = tmp_path / "home" / ".config" / "flow"
|
||||
flow_pkg = dotfiles / "flow" / ".config" / "flow"
|
||||
|
||||
flow_pkg.mkdir(parents=True)
|
||||
home_config.mkdir(parents=True)
|
||||
|
||||
# Create manifest in dotfiles
|
||||
manifest_content = {"profiles": {"from-dotfiles": {"os": "linux"}}}
|
||||
(flow_pkg / "manifest.yaml").write_text(yaml.dump(manifest_content))
|
||||
|
||||
# Create symlink from home config to dotfiles
|
||||
manifest_link = home_config / "manifest.yaml"
|
||||
manifest_link.symlink_to(flow_pkg / "manifest.yaml")
|
||||
|
||||
# Mock paths
|
||||
monkeypatch.setattr(paths_module, "DOTFILES_MANIFEST", flow_pkg / "manifest.yaml")
|
||||
monkeypatch.setattr(paths_module, "MANIFEST_FILE", manifest_link)
|
||||
|
||||
# Load - should work through symlink
|
||||
manifest = load_manifest()
|
||||
assert "from-dotfiles" in manifest.get("profiles", {})
|
||||
|
||||
|
||||
def test_config_priority_documentation(mock_paths):
|
||||
"""Document the config loading priority for users."""
|
||||
# This test serves as documentation of the cascade behavior
|
||||
|
||||
# Priority 1: Dotfiles repo (self-hosted)
|
||||
dotfiles_manifest = mock_paths["dotfiles_manifest"]
|
||||
dotfiles_manifest.parent.mkdir(parents=True)
|
||||
dotfiles_manifest.write_text("profiles:\n priority-1: {}")
|
||||
|
||||
manifest = load_manifest()
|
||||
assert "priority-1" in manifest.get("profiles", {})
|
||||
|
||||
# If we remove dotfiles, falls back to Priority 2: Local override
|
||||
dotfiles_manifest.unlink()
|
||||
local_manifest = mock_paths["local_manifest"]
|
||||
local_manifest.write_text("profiles:\n priority-2: {}")
|
||||
|
||||
manifest = load_manifest()
|
||||
assert "priority-2" in manifest.get("profiles", {})
|
||||
|
||||
# If neither exists, Priority 3: Empty fallback
|
||||
local_manifest.unlink()
|
||||
manifest = load_manifest()
|
||||
assert manifest == {}
|
||||
310
tests/test_stow.py
Normal file
310
tests/test_stow.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""Tests for flow.core.stow — GNU Stow-style tree folding/unfolding."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.stow import LinkOperation, LinkTree, TreeFolder
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_home(tmp_path):
|
||||
"""Create a temporary home directory."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dotfiles(tmp_path):
|
||||
"""Create a temporary dotfiles repository."""
|
||||
dotfiles = tmp_path / "dotfiles"
|
||||
dotfiles.mkdir()
|
||||
return dotfiles
|
||||
|
||||
|
||||
def test_linktree_add_remove():
|
||||
"""Test basic LinkTree operations."""
|
||||
tree = LinkTree()
|
||||
source = Path("/dotfiles/zsh/.zshrc")
|
||||
target = Path("/home/user/.zshrc")
|
||||
|
||||
tree.add_link(target, source, "zsh", is_dir_link=False)
|
||||
assert target in tree.links
|
||||
assert tree.links[target] == source
|
||||
assert tree.packages[target] == "zsh"
|
||||
assert not tree.is_directory_link(target)
|
||||
|
||||
tree.remove_link(target)
|
||||
assert target not in tree.links
|
||||
assert target not in tree.packages
|
||||
|
||||
|
||||
def test_linktree_directory_link():
|
||||
"""Test directory link tracking."""
|
||||
tree = LinkTree()
|
||||
source = Path("/dotfiles/nvim/.config/nvim")
|
||||
target = Path("/home/user/.config/nvim")
|
||||
|
||||
tree.add_link(target, source, "nvim", is_dir_link=True)
|
||||
assert tree.is_directory_link(target)
|
||||
|
||||
|
||||
def test_linktree_can_fold_single_package():
|
||||
"""Test can_fold with single package."""
|
||||
tree = LinkTree()
|
||||
target_dir = Path("/home/user/.config/nvim")
|
||||
|
||||
# Add files from same package
|
||||
tree.add_link(target_dir / "init.lua", Path("/dotfiles/nvim/.config/nvim/init.lua"), "nvim")
|
||||
tree.add_link(target_dir / "lua" / "config.lua", Path("/dotfiles/nvim/.config/nvim/lua/config.lua"), "nvim")
|
||||
|
||||
# Should be able to fold since all files are from same package
|
||||
assert tree.can_fold(target_dir, "nvim")
|
||||
|
||||
|
||||
def test_linktree_can_fold_multiple_packages():
|
||||
"""Test can_fold with multiple packages."""
|
||||
tree = LinkTree()
|
||||
target_dir = Path("/home/user/.config")
|
||||
|
||||
# Add files from different packages
|
||||
tree.add_link(target_dir / "nvim", Path("/dotfiles/nvim/.config/nvim"), "nvim", is_dir_link=True)
|
||||
tree.add_link(target_dir / "tmux", Path("/dotfiles/tmux/.config/tmux"), "tmux", is_dir_link=True)
|
||||
|
||||
# Cannot fold .config since it has files from multiple packages
|
||||
assert not tree.can_fold(target_dir, "nvim")
|
||||
|
||||
|
||||
def test_linktree_from_state_old_format_rejected():
|
||||
"""Old state format should be rejected (no backward compatibility)."""
|
||||
state = {
|
||||
"links": {
|
||||
"zsh": {
|
||||
"/home/user/.zshrc": "/dotfiles/zsh/.zshrc",
|
||||
"/home/user/.zshenv": "/dotfiles/zsh/.zshenv",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unsupported linked state format"):
|
||||
LinkTree.from_state(state)
|
||||
|
||||
|
||||
def test_linktree_from_state_new_format():
|
||||
"""Test loading from new state format (with is_directory_link)."""
|
||||
state = {
|
||||
"version": 2,
|
||||
"links": {
|
||||
"nvim": {
|
||||
"/home/user/.config/nvim": {
|
||||
"source": "/dotfiles/nvim/.config/nvim",
|
||||
"is_directory_link": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tree = LinkTree.from_state(state)
|
||||
target = Path("/home/user/.config/nvim")
|
||||
assert target in tree.links
|
||||
assert tree.is_directory_link(target)
|
||||
assert tree.packages[target] == "nvim"
|
||||
|
||||
|
||||
def test_linktree_to_state():
|
||||
"""Test converting LinkTree to state format."""
|
||||
tree = LinkTree()
|
||||
tree.add_link(
|
||||
Path("/home/user/.config/nvim"),
|
||||
Path("/dotfiles/nvim/.config/nvim"),
|
||||
"nvim",
|
||||
is_dir_link=True,
|
||||
)
|
||||
tree.add_link(
|
||||
Path("/home/user/.zshrc"),
|
||||
Path("/dotfiles/zsh/.zshrc"),
|
||||
"zsh",
|
||||
is_dir_link=False,
|
||||
)
|
||||
|
||||
state = tree.to_state()
|
||||
assert state["version"] == 2
|
||||
assert "nvim" in state["links"]
|
||||
assert "zsh" in state["links"]
|
||||
|
||||
nvim_link = state["links"]["nvim"]["/home/user/.config/nvim"]
|
||||
assert nvim_link["is_directory_link"] is True
|
||||
|
||||
zsh_link = state["links"]["zsh"]["/home/user/.zshrc"]
|
||||
assert zsh_link["is_directory_link"] is False
|
||||
|
||||
|
||||
def test_treefolder_plan_link_simple(temp_home, temp_dotfiles):
|
||||
"""Test planning a simple file link."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = temp_dotfiles / "zsh" / ".zshrc"
|
||||
target = temp_home / ".zshrc"
|
||||
|
||||
# Create source file
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("# zshrc")
|
||||
|
||||
ops = folder.plan_link(source, target, "zsh")
|
||||
assert len(ops) == 1
|
||||
assert ops[0].type == "create_symlink"
|
||||
assert ops[0].source == source
|
||||
assert ops[0].target == target
|
||||
assert ops[0].package == "zsh"
|
||||
|
||||
|
||||
def test_treefolder_detect_conflicts_existing_file(temp_home, temp_dotfiles):
|
||||
"""Test conflict detection for existing files."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = temp_dotfiles / "zsh" / ".zshrc"
|
||||
target = temp_home / ".zshrc"
|
||||
|
||||
# Create existing file
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("# existing")
|
||||
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("# zshrc")
|
||||
|
||||
ops = folder.plan_link(source, target, "zsh")
|
||||
conflicts = folder.detect_conflicts(ops)
|
||||
|
||||
assert len(conflicts) == 1
|
||||
assert "already exists" in conflicts[0]
|
||||
|
||||
|
||||
def test_treefolder_detect_conflicts_different_package(temp_home, temp_dotfiles):
|
||||
"""Test conflict detection for links from different packages."""
|
||||
tree = LinkTree()
|
||||
target = temp_home / ".bashrc"
|
||||
|
||||
# Add existing link from different package
|
||||
tree.add_link(target, Path("/dotfiles/bash/.bashrc"), "bash")
|
||||
|
||||
folder = TreeFolder(tree)
|
||||
source = temp_dotfiles / "zsh" / ".bashrc"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("# bashrc")
|
||||
|
||||
ops = folder.plan_link(source, target, "zsh")
|
||||
conflicts = folder.detect_conflicts(ops)
|
||||
|
||||
assert len(conflicts) == 1
|
||||
assert "bash" in conflicts[0]
|
||||
|
||||
|
||||
def test_treefolder_execute_operations_dry_run(temp_home, temp_dotfiles, capsys):
|
||||
"""Test dry-run mode."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = temp_dotfiles / "zsh" / ".zshrc"
|
||||
target = temp_home / ".zshrc"
|
||||
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("# zshrc")
|
||||
|
||||
ops = folder.plan_link(source, target, "zsh")
|
||||
folder.execute_operations(ops, dry_run=True)
|
||||
|
||||
# Check output
|
||||
captured = capsys.readouterr()
|
||||
assert "FILE LINK" in captured.out
|
||||
assert str(target) in captured.out
|
||||
|
||||
# No actual symlink created
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_treefolder_execute_operations_create_symlink(temp_home, temp_dotfiles):
|
||||
"""Test creating actual symlinks."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = temp_dotfiles / "zsh" / ".zshrc"
|
||||
target = temp_home / ".zshrc"
|
||||
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("# zshrc")
|
||||
|
||||
ops = folder.plan_link(source, target, "zsh")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Check symlink created
|
||||
assert target.is_symlink()
|
||||
assert target.resolve() == source.resolve()
|
||||
|
||||
# Check tree updated
|
||||
assert target in folder.tree.links
|
||||
|
||||
|
||||
def test_treefolder_plan_unlink(temp_home, temp_dotfiles):
|
||||
"""Test planning unlink operations."""
|
||||
tree = LinkTree()
|
||||
target = temp_home / ".zshrc"
|
||||
source = temp_dotfiles / "zsh" / ".zshrc"
|
||||
|
||||
tree.add_link(target, source, "zsh")
|
||||
|
||||
folder = TreeFolder(tree)
|
||||
ops = folder.plan_unlink(target, "zsh")
|
||||
|
||||
assert len(ops) == 1
|
||||
assert ops[0].type == "remove"
|
||||
assert ops[0].target == target
|
||||
|
||||
|
||||
def test_treefolder_plan_unlink_directory_link(temp_home, temp_dotfiles):
|
||||
"""Test planning unlink for directory symlink."""
|
||||
tree = LinkTree()
|
||||
target = temp_home / ".config" / "nvim"
|
||||
source = temp_dotfiles / "nvim" / ".config" / "nvim"
|
||||
|
||||
tree.add_link(target, source, "nvim", is_dir_link=True)
|
||||
|
||||
folder = TreeFolder(tree)
|
||||
ops = folder.plan_unlink(target, "nvim")
|
||||
|
||||
# Should remove the directory link
|
||||
assert len(ops) >= 1
|
||||
assert ops[-1].type == "remove"
|
||||
assert ops[-1].is_directory_link
|
||||
|
||||
|
||||
def test_linkoperation_str():
|
||||
"""Test LinkOperation string representation."""
|
||||
op1 = LinkOperation(
|
||||
type="create_symlink",
|
||||
source=Path("/src"),
|
||||
target=Path("/dst"),
|
||||
package="test",
|
||||
is_directory_link=False,
|
||||
)
|
||||
assert "FILE LINK" in str(op1)
|
||||
|
||||
op2 = LinkOperation(
|
||||
type="create_symlink",
|
||||
source=Path("/src"),
|
||||
target=Path("/dst"),
|
||||
package="test",
|
||||
is_directory_link=True,
|
||||
)
|
||||
assert "DIR LINK" in str(op2)
|
||||
|
||||
op3 = LinkOperation(
|
||||
type="unfold",
|
||||
source=Path("/src"),
|
||||
target=Path("/dst"),
|
||||
package="test",
|
||||
)
|
||||
assert "UNFOLD" in str(op3)
|
||||
52
tests/test_variables.py
Normal file
52
tests/test_variables.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for flow.core.variables."""
|
||||
|
||||
from flow.core.variables import substitute, substitute_template
|
||||
|
||||
|
||||
def test_substitute_dollar():
|
||||
result = substitute("hello $NAME", {"NAME": "world"})
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_substitute_braces():
|
||||
result = substitute("hello ${NAME}", {"NAME": "world"})
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_substitute_multiple():
|
||||
result = substitute("$A and ${B}", {"A": "1", "B": "2"})
|
||||
assert result == "1 and 2"
|
||||
|
||||
|
||||
def test_substitute_home():
|
||||
result = substitute("dir=$HOME", {})
|
||||
assert "$HOME" not in result
|
||||
|
||||
|
||||
def test_substitute_user():
|
||||
import os
|
||||
result = substitute("u=$USER", {})
|
||||
assert result == f"u={os.getenv('USER', '')}"
|
||||
|
||||
|
||||
def test_substitute_non_string():
|
||||
assert substitute(123, {}) == 123
|
||||
|
||||
|
||||
def test_substitute_template_basic():
|
||||
result = substitute_template("nvim-{{os}}-{{arch}}.tar.gz", {"os": "linux", "arch": "x86_64"})
|
||||
assert result == "nvim-linux-x86_64.tar.gz"
|
||||
|
||||
|
||||
def test_substitute_template_missing_key():
|
||||
result = substitute_template("{{missing}}", {})
|
||||
assert result == "{{missing}}"
|
||||
|
||||
|
||||
def test_substitute_template_non_string():
|
||||
assert substitute_template(42, {}) == 42
|
||||
|
||||
|
||||
def test_substitute_template_no_placeholders():
|
||||
result = substitute_template("plain text", {"os": "linux"})
|
||||
assert result == "plain text"
|
||||
Reference in New Issue
Block a user