Rewrite CLI around action runtime
This commit is contained in:
140
tests/test_actions_executor.py
Normal file
140
tests/test_actions_executor.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for the canonical action executor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.actions import ActionExecutor, ActionPlan, PrimitiveAction, RollbackPolicy
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import Console
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.runtime import SystemRuntime
|
||||
|
||||
|
||||
def _ctx() -> FlowContext:
|
||||
return FlowContext(
|
||||
config=AppConfig(),
|
||||
manifest={},
|
||||
platform=PlatformInfo(),
|
||||
console=Console(color=False),
|
||||
runtime=SystemRuntime(),
|
||||
)
|
||||
|
||||
|
||||
def test_dry_run_does_not_mutate(tmp_path):
|
||||
target = tmp_path / "out.txt"
|
||||
plan = ActionPlan(
|
||||
name="dry-run",
|
||||
primitive_actions=(
|
||||
PrimitiveAction(
|
||||
id="write",
|
||||
type="file.write",
|
||||
description="Write a file",
|
||||
payload={"path": target, "content": "hello"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
summary = ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(
|
||||
plan,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert not target.exists()
|
||||
assert summary.results[0].status == "dry-run"
|
||||
|
||||
|
||||
def test_audit_jsonl_records_success(tmp_path):
|
||||
target = tmp_path / "out.txt"
|
||||
audit_path = tmp_path / "actions.jsonl"
|
||||
plan = ActionPlan(
|
||||
name="write-plan",
|
||||
primitive_actions=(
|
||||
PrimitiveAction(
|
||||
id="write",
|
||||
type="file.write",
|
||||
description="Write a file",
|
||||
payload={"path": target, "content": "hello"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
ActionExecutor(_ctx(), audit_path=audit_path).execute(plan)
|
||||
|
||||
records = [json.loads(line) for line in audit_path.read_text().splitlines()]
|
||||
assert [record["event"] for record in records] == [
|
||||
"plan_start",
|
||||
"action_start",
|
||||
"action_success",
|
||||
"plan_success",
|
||||
]
|
||||
assert target.read_text() == "hello"
|
||||
|
||||
|
||||
def test_rollback_removes_created_symlink_after_failure(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.write_text("managed")
|
||||
plan = ActionPlan(
|
||||
name="rollback",
|
||||
primitive_actions=(
|
||||
PrimitiveAction(
|
||||
id="link",
|
||||
type="file.create_symlink",
|
||||
description="Create managed link",
|
||||
payload={"source": source, "target": target},
|
||||
),
|
||||
PrimitiveAction(
|
||||
id="copy-missing",
|
||||
type="file.copy",
|
||||
description="Fail on missing source",
|
||||
payload={"source": tmp_path / "missing", "target": tmp_path / "dest"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(FlowError, match="missing"):
|
||||
ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan)
|
||||
|
||||
assert not target.exists()
|
||||
assert not target.is_symlink()
|
||||
|
||||
|
||||
def test_barrier_prevents_rollback_across_external_boundary(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.write_text("managed")
|
||||
plan = ActionPlan(
|
||||
name="barrier",
|
||||
primitive_actions=(
|
||||
PrimitiveAction(
|
||||
id="link",
|
||||
type="file.create_symlink",
|
||||
description="Create managed link",
|
||||
payload={"source": source, "target": target},
|
||||
),
|
||||
PrimitiveAction(
|
||||
id="barrier",
|
||||
type="process.argv",
|
||||
description="External boundary",
|
||||
payload={"argv": (sys.executable, "-c", "")},
|
||||
rollback_policy=RollbackPolicy.BARRIER,
|
||||
),
|
||||
PrimitiveAction(
|
||||
id="copy-missing",
|
||||
type="file.copy",
|
||||
description="Fail on missing source",
|
||||
payload={"source": tmp_path / "missing", "target": tmp_path / "dest"},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(FlowError, match="missing"):
|
||||
ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan)
|
||||
|
||||
assert target.is_symlink()
|
||||
|
||||
@@ -4,6 +4,19 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from flow.cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_typer_runner_version():
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "flow" in result.stdout
|
||||
|
||||
|
||||
def test_version_flag():
|
||||
"""Test --version flag works."""
|
||||
|
||||
@@ -9,7 +9,7 @@ import yaml
|
||||
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import Console
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core.errors import FlowError, PlanConflict
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.runtime import SystemRuntime
|
||||
from flow.core import paths
|
||||
@@ -187,7 +187,7 @@ class TestDotfilesServiceLink:
|
||||
output = capsys.readouterr().out
|
||||
assert "zsh" in output
|
||||
|
||||
def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch):
|
||||
def test_relink_fails_on_unmanaged_file(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
|
||||
@@ -208,7 +208,8 @@ class TestDotfilesServiceLink:
|
||||
target.unlink()
|
||||
target.write_text("user managed file")
|
||||
|
||||
svc.link()
|
||||
with pytest.raises(PlanConflict):
|
||||
svc.link()
|
||||
assert target.read_text() == "user managed file"
|
||||
assert not target.is_symlink()
|
||||
|
||||
@@ -619,12 +620,10 @@ class TestUnclonedModuleWarning:
|
||||
), console.warnings
|
||||
|
||||
|
||||
class TestOrphanAdoption:
|
||||
"""After a partial-failure rerun the planner adopts pre-existing matching
|
||||
symlinks. We simulate a failure during _apply_plan by replacing
|
||||
create_symlink mid-flight, then re-running link()."""
|
||||
class TestActionRollback:
|
||||
"""A mid-execution failure rolls back created links and leaves state absent."""
|
||||
|
||||
def test_partial_apply_failure_recoverable_via_rerun(self, tmp_path, monkeypatch):
|
||||
def test_partial_apply_failure_rolls_back_then_rerun_succeeds(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
dotfiles = _setup_dotfiles(tmp_path, {
|
||||
@@ -659,13 +658,13 @@ class TestOrphanAdoption:
|
||||
# State file should NOT have been written (atomic semantics: we
|
||||
# only persist when _apply_plan completes).
|
||||
assert not state_path.exists()
|
||||
# First symlink landed on disk.
|
||||
# The first symlink was rolled back.
|
||||
existing_links = sorted(
|
||||
p.name for p in home.rglob("*") if p.is_symlink()
|
||||
)
|
||||
assert len(existing_links) == 1
|
||||
assert existing_links == []
|
||||
|
||||
# Restore real implementation and re-run: orphan adoption kicks in.
|
||||
# Restore real implementation and re-run.
|
||||
monkeypatch.setattr(ctx.runtime.fs, "create_symlink", real_create)
|
||||
svc.link()
|
||||
|
||||
|
||||
@@ -57,8 +57,8 @@ class TestPackageService:
|
||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||
ctx = _make_ctx(tmp_path)
|
||||
svc = PackageService(ctx)
|
||||
svc.remove(["missing"])
|
||||
assert "No matching" in capsys.readouterr().out
|
||||
with pytest.raises(FlowError, match="not installed"):
|
||||
svc.remove(["missing"])
|
||||
|
||||
def test_install_requires_args(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||
@@ -106,7 +106,7 @@ class TestPackageService:
|
||||
def read(self):
|
||||
return archive_bytes
|
||||
|
||||
monkeypatch.setattr("flow.services.packages.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse())
|
||||
monkeypatch.setattr("flow.adapters.download.urllib.request.urlopen", lambda *args, **kwargs: FakeResponse())
|
||||
|
||||
manifest = {
|
||||
"packages": [{
|
||||
@@ -176,7 +176,7 @@ class TestPackageService:
|
||||
raise urllib.error.URLError("Network unreachable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"flow.services.packages.urllib.request.urlopen", _raise,
|
||||
"flow.adapters.download.urllib.request.urlopen", _raise,
|
||||
)
|
||||
|
||||
manifest = {
|
||||
|
||||
45
tests/test_static_mutation_guard.py
Normal file
45
tests/test_static_mutation_guard.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Static guard for direct filesystem mutation outside adapters/actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MUTATING_PATTERNS = (
|
||||
re.compile(r"\.(mkdir|unlink|write_text|write_bytes|symlink_to|chmod)\("),
|
||||
re.compile(r"\b(os\.replace|shutil\.rmtree|shutil\.copy2|shutil\.copytree)\("),
|
||||
re.compile(r"runtime\.fs\.(create_symlink|remove_symlink|write_json|write_text|write_bytes|copy_file|copy_tree|remove_file|remove_tree)\("),
|
||||
)
|
||||
|
||||
ALLOWED_PREFIXES = (
|
||||
Path("src/flow/adapters"),
|
||||
Path("src/flow/actions"),
|
||||
Path("tests"),
|
||||
)
|
||||
|
||||
ALLOWED_FILES = {
|
||||
Path("src/flow/core/paths.py"),
|
||||
}
|
||||
|
||||
SKIPPED_LEGACY_COMMANDS = {
|
||||
Path("src/flow/commands/completion.py"),
|
||||
}
|
||||
|
||||
|
||||
def test_no_direct_filesystem_mutation_outside_action_boundary():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
offenders: list[str] = []
|
||||
for path in sorted((root / "src" / "flow").rglob("*.py")):
|
||||
rel = path.relative_to(root)
|
||||
if rel in ALLOWED_FILES or rel in SKIPPED_LEGACY_COMMANDS:
|
||||
continue
|
||||
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
|
||||
continue
|
||||
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if "Service(" in line:
|
||||
continue
|
||||
if any(pattern.search(line) for pattern in MUTATING_PATTERNS):
|
||||
offenders.append(f"{rel}:{line_no}: {line.strip()}")
|
||||
|
||||
assert offenders == []
|
||||
Reference in New Issue
Block a user