Files
flow/tests/test_service_dotfiles.py
2026-03-16 08:06:25 +02:00

239 lines
8.0 KiB
Python

"""Tests for DotfilesService."""
import subprocess
from pathlib import Path
import yaml
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import CommandRunner, SystemRuntime
from flow.core import paths
from flow.services.dotfiles import DotfilesService
class FakeRunner(CommandRunner):
def __init__(self):
self.calls: list[list[str]] = []
def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None):
command = [str(part) for part in argv]
self.calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def _make_ctx(tmp_path, console=None):
"""Build a FlowContext for testing."""
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=console or Console(color=False),
runtime=SystemRuntime(),
)
def _setup_dotfiles(tmp_path, packages_files):
"""Set up a fake dotfiles directory structure.
packages_files: dict of {package_name: {relative_path: content}}
"""
dotfiles = tmp_path / "dotfiles"
shared = dotfiles / "_shared"
for pkg_name, files in packages_files.items():
pkg_dir = shared / pkg_name
for rel_path, content in files.items():
file_path = pkg_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return dotfiles
class TestDotfilesServiceLink:
def test_link_creates_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh config"},
"git": {".config/git/config": "[user]\n name = test"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".config" / "git" / "config").is_symlink()
def test_link_with_module(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
# Set up package with _module.yaml
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
# Set up local file outside mount path
(pkg_dir / ".local" / "bin").mkdir(parents=True)
(pkg_dir / ".local" / "bin" / "nvim-wrapper").write_text("#!/bin/sh")
# Set up cloned module
module_dir = modules / "_shared--nvim"
module_dir.mkdir(parents=True)
(module_dir / "init.lua").write_text("-- init")
(module_dir / "lua").mkdir()
(module_dir / "lua" / "plugins.lua").write_text("-- plugins")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
# Module files should be linked under .config/nvim/
assert (home / ".config" / "nvim" / "init.lua").is_symlink()
assert (home / ".config" / "nvim" / "lua" / "plugins.lua").is_symlink()
# Local file outside mount path should be linked
assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink()
def test_unlink_removes_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first
svc.link()
assert (home / ".zshrc").is_symlink()
# Then unlink
svc.unlink()
assert not (home / ".zshrc").exists()
def test_link_dry_run_no_changes(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link(dry_run=True)
# No symlinks should exist
assert not (home / ".zshrc").exists()
def test_status_shows_packages(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first to populate state
svc.link()
# Check status
svc.status()
output = capsys.readouterr().out
assert "zsh" in output
def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
target = home / ".zshrc"
target.unlink()
target.write_text("user managed file")
svc.link()
assert target.read_text() == "user managed file"
assert not target.is_symlink()
def test_sync_modules_includes_profile_layers(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
profile_pkg = dotfiles / "linux-work" / "nvim" / ".config" / "nvim"
profile_pkg.mkdir(parents=True)
(profile_pkg / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).sync_modules()
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)