Files
flow/tests/test_core_runtime.py
Tomas Mirchev 6b7a48bb20 refactor: post-review hardening pass
Independent re-audit surfaced 11 follow-ups across two layers of review
(my fresh-eyes read + a parallel agent pass). Bundled into a single
commit because changes are small and intertwined.

Symlink / state consistency:
- FileSystem.same_symlink now uses raw readlink() instead of resolve().
  Aligns the three sites that ask "is this our link?" (_load_state,
  _check_overwrite_safe, remove_symlink) on a single rule: exact-readlink
  match. Following symlink chains would let externally-modified links
  pass as ours and be silently overwritten.
- LinkedState.from_dict raises ConfigError on missing required fields
  instead of .get(..., False) silent defaults. Matches InstalledState.
- LinkOp.source is now consistently None for remove_link ops; the
  service derives expected_source from current.links. Removes the
  asymmetry between in-state and orphan-broken removal ops.
- _apply_plan: rename shadowing local from link_target to spec.

Fail loud:
- _xdg() now treats XDG_CONFIG_HOME="" the same as unset. Previously
  an empty env var produced Path("") and state files were written to
  $PWD instead of ~/.local/state/flow.
- _resolve_target raises PlanConflict when a package contains a bare
  _root entry (no path components) instead of silently dropping it.
- _strip_prefix raises FlowError when a declared install path does not
  start with its section's expected prefix (e.g. etc/foo under install.bin).

Speculative abstraction removed (CLAUDE.md):
- core.template.substitute (the $VAR form) had no production callers --
  deleted along with its tests; only the {{var}} form remains.
- SetupModule base class -- five subclasses, no shared behaviour, no
  polymorphic call site. Deleted.
- Profile.arch -- parsed but never read. Deleted.
- PackagePlan.pm_command -- set but never read. Deleted (service
  recomputes pm_install_command at the call site).
- FileSystem.ensure_dir(mode=...), .copy_file(sudo=...), .read_text(
  default=...) -- no callers. Deleted along with their test.
- bootstrap _execute_action: the upfront `phase not in VALID_PHASES`
  check duplicated the trailing exhaustive raise. Kept the trailing
  raise as the single source of truth; phase set still documented in
  VALID_PHASES.

Completion ctx threading:
- Removed _config()/_manifest() helpers that re-loaded from disk on
  every completion call. _list_targets, _list_namespaces, _list_platforms,
  _list_bootstrap_profiles, _list_manifest_packages now take ctx and
  read from ctx.config / ctx.manifest.

Test coverage and e2e:
- e2e container test exercises a real `flow dotfiles link` (no dry-run)
  and asserts the resulting symlinks point into the dotfiles dir;
  reruns to verify idempotency.
- New tests: LinkedState corrupt-state ConfigError, LinkedState bad-version
  ConfigError, bare-_root PlanConflict, service-level _root path routing
  + skip semantics.
- 11 stale test imports removed (pyflakes clean across src/ + tests/).

357 unit tests + 1 e2e (gated) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:23:06 +03:00

229 lines
7.9 KiB
Python

"""Tests for flow.core.runtime."""
import json
import os
import pytest
from flow.core.containers import ContainerRuntime
from flow.core.errors import FlowError
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
from flow.core.tmux import TmuxClient
class TestFileSystem:
def test_ensure_dir_creates_nested(self, tmp_path):
fs = FileSystem()
target = tmp_path / "a" / "b" / "c"
fs.ensure_dir(target)
assert target.is_dir()
def test_write_and_read_text(self, tmp_path):
fs = FileSystem()
path = tmp_path / "test.txt"
fs.write_text(path, "hello")
assert fs.read_text(path) == "hello"
def test_write_and_read_json(self, tmp_path):
fs = FileSystem()
path = tmp_path / "data.json"
fs.write_json(path, {"key": "value"})
assert fs.read_json(path) == {"key": "value"}
def test_create_symlink(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("content")
target = tmp_path / "link"
fs.create_symlink(source, target)
assert target.is_symlink()
assert target.resolve() == source.resolve()
def test_same_symlink_true(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("content")
target = tmp_path / "link"
target.symlink_to(source)
assert fs.same_symlink(target, source) is True
def test_same_symlink_false(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("content")
other = tmp_path / "other"
other.write_text("other")
target = tmp_path / "link"
target.symlink_to(other)
assert fs.same_symlink(target, source) is False
def test_remove_file(self, tmp_path):
fs = FileSystem()
path = tmp_path / "file"
path.write_text("x")
fs.remove_file(path)
assert not path.exists()
def test_remove_file_missing_ok(self, tmp_path):
fs = FileSystem()
fs.remove_file(tmp_path / "missing", missing_ok=True) # no error
def test_copy_file(self, tmp_path):
fs = FileSystem()
src = tmp_path / "src"
src.write_text("data")
dst = tmp_path / "sub" / "dst"
fs.copy_file(src, dst)
assert dst.read_text() == "data"
def test_write_json_atomic_leaves_no_tmp_residue(self, tmp_path):
fs = FileSystem()
path = tmp_path / "state" / "data.json"
fs.write_json(path, {"a": 1})
assert path.read_text()
assert json.loads(path.read_text()) == {"a": 1}
residue = list(path.parent.glob("*.tmp"))
assert residue == []
def test_write_json_overwrites_stale_tmp(self, tmp_path):
fs = FileSystem()
path = tmp_path / "data.json"
stale = path.with_suffix(path.suffix + ".tmp")
stale.write_text("garbage")
fs.write_json(path, {"k": "v"})
assert json.loads(path.read_text()) == {"k": "v"}
assert not stale.exists()
def test_remove_tree_raises_when_missing(self, tmp_path):
fs = FileSystem()
with pytest.raises(FileNotFoundError):
fs.remove_tree(tmp_path / "absent")
def test_remove_tree_missing_ok(self, tmp_path):
fs = FileSystem()
fs.remove_tree(tmp_path / "absent", missing_ok=True) # no error
def test_remove_tree_raises_on_permission_error(self, tmp_path):
fs = FileSystem()
# Create a parent dir we can't traverse, holding a child directory.
# If we can't actually drop permissions (e.g. running as root), skip.
if os.geteuid() == 0:
pytest.skip("Running as root: cannot simulate permission error")
parent = tmp_path / "locked"
parent.mkdir()
(parent / "child").mkdir()
try:
parent.chmod(0o500) # r-x: can list but not modify
with pytest.raises(OSError):
fs.remove_tree(parent / "child")
finally:
parent.chmod(0o700)
def test_create_symlink_refuses_real_file(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.write_text("user content")
with pytest.raises(FlowError, match="Refusing to overwrite"):
fs.create_symlink(source, target)
# Target untouched.
assert target.read_text() == "user content"
def test_create_symlink_refuses_foreign_symlink(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
elsewhere = tmp_path / "elsewhere"
elsewhere.write_text("other")
target = tmp_path / "target"
target.symlink_to(elsewhere)
with pytest.raises(FlowError, match="Refusing to overwrite"):
fs.create_symlink(source, target)
# Foreign symlink preserved.
assert target.readlink() == elsewhere
def test_create_symlink_when_target_absent(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "sub" / "target"
fs.create_symlink(source, target)
assert target.is_symlink()
assert target.readlink() == source
def test_create_symlink_idempotent_overwrite(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.symlink_to(source)
fs.create_symlink(source, target) # must not raise
assert target.is_symlink()
assert target.readlink() == source
def test_remove_symlink_with_matching_expected(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.symlink_to(source)
fs.remove_symlink(target, expected_source=source)
assert not target.exists()
assert not target.is_symlink()
def test_remove_symlink_refuses_regular_file(self, tmp_path):
fs = FileSystem()
target = tmp_path / "target"
target.write_text("real file")
with pytest.raises(FlowError, match="Refusing to remove non-symlink"):
fs.remove_symlink(target)
assert target.read_text() == "real file"
def test_remove_symlink_refuses_mismatched_expected(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
elsewhere = tmp_path / "elsewhere"
elsewhere.write_text("other")
target = tmp_path / "target"
target.symlink_to(elsewhere)
with pytest.raises(FlowError, match="Refusing to remove symlink"):
fs.remove_symlink(target, expected_source=source)
# Untouched.
assert target.is_symlink()
assert target.readlink() == elsewhere
def test_remove_symlink_absent_is_noop(self, tmp_path):
fs = FileSystem()
fs.remove_symlink(tmp_path / "missing") # no error
class TestCommandRunner:
def test_run_echo(self):
runner = CommandRunner()
result = runner.run(["echo", "hello"], capture_output=True)
assert result.stdout.strip() == "hello"
def test_require_binary_finds_echo(self):
runner = CommandRunner()
path = runner.require_binary("echo")
assert path is not None
class TestSystemRuntime:
def test_creates_git_client(self):
rt = SystemRuntime()
assert isinstance(rt.git, GitClient)
assert rt.git.runner is rt.runner
def test_creates_tmux_client(self):
rt = SystemRuntime()
assert isinstance(rt.tmux, TmuxClient)
assert rt.tmux.runner is rt.runner
def test_creates_container_runtime(self):
rt = SystemRuntime()
assert isinstance(rt.containers, ContainerRuntime)
assert rt.containers.runner is rt.runner