"""Containerized e2e tests for dotfiles link safety. These tests are opt-in and run only when FLOW_RUN_E2E_CONTAINER=1. """ import os import shutil import subprocess import uuid from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[1] def _docker_available() -> bool: if shutil.which("docker") is None: return False result = subprocess.run( ["docker", "info"], capture_output=True, text=True, check=False, ) return result.returncode == 0 def _require_container_e2e() -> None: if os.environ.get("FLOW_RUN_E2E_CONTAINER") != "1": pytest.skip("Set FLOW_RUN_E2E_CONTAINER=1 to run container e2e tests") if not _docker_available(): pytest.skip("Docker is required for container e2e tests") @pytest.fixture(scope="module") def e2e_image(tmp_path_factory): _require_container_e2e() context_dir = tmp_path_factory.mktemp("flow-e2e-docker-context") dockerfile = context_dir / "Dockerfile" dockerfile.write_text( "FROM python:3.11-slim\n" "RUN apt-get update && apt-get install -y --no-install-recommends sudo && rm -rf /var/lib/apt/lists/*\n" "RUN pip install --no-cache-dir pyyaml\n" "RUN useradd -m -s /bin/bash flow\n" "RUN echo 'flow ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/flow && chmod 440 /etc/sudoers.d/flow\n" "USER flow\n" "WORKDIR /workspace\n" ) tag = f"flow-e2e-{uuid.uuid4().hex[:10]}" subprocess.run( ["docker", "build", "-t", tag, str(context_dir)], check=True, capture_output=True, text=True, ) try: yield tag finally: subprocess.run(["docker", "rmi", "-f", tag], capture_output=True, text=True, check=False) def _run_in_container(image_tag: str, script: str) -> subprocess.CompletedProcess: return subprocess.run( [ "docker", "run", "--rm", "-v", f"{REPO_ROOT}:/workspace/flow-cli:ro", image_tag, "bash", "-lc", script, ], capture_output=True, text=True, check=False, ) def _assert_ok(run: subprocess.CompletedProcess) -> None: if run.returncode != 0: raise AssertionError(f"Container e2e failed:\nSTDOUT:\n{run.stdout}\nSTDERR:\n{run.stderr}") def test_e2e_link_and_undo_with_root_targets(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/zsh" mkdir -p "$dot/_shared/rootpkg/_root/tmp" echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc" echo 'root-target' > "$dot/_shared/rootpkg/_root/tmp/flow-e2e-root-target" echo '# before' > "$HOME/.zshrc" PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force test -L "$HOME/.zshrc" test -L /tmp/flow-e2e-root-target PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles undo test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# before$' "$HOME/.zshrc" test ! -e /tmp/flow-e2e-root-target """ _assert_ok(_run_in_container(e2e_image, script)) def test_e2e_dry_run_force_is_read_only_in_both_flag_orders(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/zsh" echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc" echo '# original' > "$HOME/.zshrc" PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --dry-run --force PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force --dry-run test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# original$' "$HOME/.zshrc" state="$XDG_STATE_HOME/flow/linked.json" if [ -f "$state" ]; then python - "$state" <<'PY' import json, sys data = json.load(open(sys.argv[1], encoding="utf-8")) assert data.get("links", {}) == {}, data assert "last_transaction" not in data, data PY fi """ _assert_ok(_run_in_container(e2e_image, script)) def test_e2e_unmanaged_conflict_without_force_is_non_destructive(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/zsh" echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc" echo '# user-file' > "$HOME/.zshrc" set +e PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link rc=$? set -e test "$rc" -ne 0 test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# user-file$' "$HOME/.zshrc" """ _assert_ok(_run_in_container(e2e_image, script)) def test_e2e_managed_drift_requires_force(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/zsh" echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc" PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force test -L "$HOME/.zshrc" rm -f "$HOME/.zshrc" echo '# drifted-manual' > "$HOME/.zshrc" set +e PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link rc=$? set -e test "$rc" -ne 0 test -f "$HOME/.zshrc" test ! -L "$HOME/.zshrc" grep -q '^# drifted-manual$' "$HOME/.zshrc" """ _assert_ok(_run_in_container(e2e_image, script)) def test_e2e_directory_conflict_is_atomic_even_with_force(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/zsh" "$dot/_shared/git" echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc" echo '[user]' > "$dot/_shared/git/.gitconfig" mkdir -p "$HOME/.zshrc" set +e PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force rc=$? set -e test "$rc" -ne 0 test -d "$HOME/.zshrc" test ! -e "$HOME/.gitconfig" """ _assert_ok(_run_in_container(e2e_image, script)) def test_e2e_undo_after_failed_followup_link_restores_last_transaction(e2e_image): script = r""" set -euo pipefail export HOME=/home/flow export XDG_DATA_HOME=/tmp/xdg-data export XDG_CONFIG_HOME=/tmp/xdg-config export XDG_STATE_HOME=/tmp/xdg-state mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow" dot="$XDG_DATA_HOME/flow/dotfiles" mkdir -p "$dot/_shared/a" "$dot/_shared/b" echo '# aaa' > "$dot/_shared/a/.a" echo '# bbb' > "$dot/_shared/b/.b" echo '# pre-a' > "$HOME/.a" echo '# pre-b' > "$HOME/.b" PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force a test -L "$HOME/.a" # Turn .b into a directory to force a fatal conflict, while .a stays desired and unchanged. rm -f "$HOME/.b" mkdir -p "$HOME/.b" set +e PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force rc=$? set -e test "$rc" -ne 0 PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles undo test -f "$HOME/.a" test ! -L "$HOME/.a" grep -q '^# pre-a$' "$HOME/.a" """ _assert_ok(_run_in_container(e2e_image, script))