436 lines
15 KiB
Python
436 lines
15 KiB
Python
"""Tests for DotfilesService."""
|
|
|
|
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 SystemRuntime
|
|
from flow.core import paths
|
|
from flow.services.dotfiles import DotfilesService
|
|
from tests.fakes import FakeRunner
|
|
|
|
|
|
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_status_shows_module_info(self, tmp_path, monkeypatch, capsys):
|
|
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 cloned module
|
|
module_dir = modules / "_shared--nvim"
|
|
module_dir.mkdir(parents=True)
|
|
(module_dir / "init.lua").write_text("-- init")
|
|
|
|
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()
|
|
svc.status()
|
|
output = capsys.readouterr().out
|
|
assert "nvim" in output
|
|
assert "branch:main" in output
|
|
|
|
def test_repos_list_shows_dotfiles_and_modules(self, tmp_path, monkeypatch, capsys):
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
|
|
dotfiles = tmp_path / "dotfiles"
|
|
modules = tmp_path / "modules"
|
|
|
|
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"},
|
|
}))
|
|
|
|
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.repos_list()
|
|
output = capsys.readouterr().out
|
|
assert "dotfiles" in output
|
|
assert "nvim" in output
|
|
assert "module" in output
|
|
|
|
def test_repos_pull_includes_profile_module_repos(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).repos_pull()
|
|
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)
|
|
|
|
def test_repos_status_shows_repo_names(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")
|
|
|
|
# Make dotfiles dir look like a git repo for status
|
|
(dotfiles / ".git").mkdir()
|
|
|
|
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).repos_status()
|
|
output = capsys.readouterr().out
|
|
assert "dotfiles" in output
|
|
|
|
def test_repos_push_calls_git_push(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")
|
|
|
|
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).repos_push()
|
|
assert any("push" in " ".join(call) for call in runner.calls)
|
|
|
|
def test_repos_pull_dry_run_no_calls(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")
|
|
|
|
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).repos_pull(dry_run=True)
|
|
output = capsys.readouterr().out
|
|
assert "Would" in output
|
|
# No git calls should be made in dry run
|
|
assert not runner.calls
|
|
|
|
def test_status_filter_by_package(self, tmp_path, monkeypatch, capsys):
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
|
|
dotfiles = _setup_dotfiles(tmp_path, {
|
|
"zsh": {".zshrc": "# zsh"},
|
|
"git": {".gitconfig": "[user]"},
|
|
})
|
|
|
|
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()
|
|
capsys.readouterr() # discard link output
|
|
svc.status(package_filter=["zsh"])
|
|
output = capsys.readouterr().out
|
|
assert "zsh" in output
|
|
# Only zsh should appear, not git
|
|
assert "_shared/git" not in output
|
|
|
|
def test_link_repairs_broken_symlinks(self, tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
|
|
dotfiles = _setup_dotfiles(tmp_path, {
|
|
"zsh": {".zshrc": "# zsh config"},
|
|
})
|
|
|
|
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 normally
|
|
svc.link()
|
|
assert (home / ".zshrc").is_symlink()
|
|
|
|
# Break the symlink by removing its target
|
|
real_target = (home / ".zshrc").resolve()
|
|
(home / ".zshrc").unlink()
|
|
(home / ".zshrc").symlink_to("/nonexistent/path")
|
|
|
|
# Re-link should repair the broken symlink
|
|
svc.link()
|
|
assert (home / ".zshrc").is_symlink()
|
|
assert (home / ".zshrc").resolve() == real_target.resolve()
|