From 6a0eb9f6ef8f38ab9236ae41e6094f75c859f6b3 Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Thu, 14 May 2026 00:02:19 +0300 Subject: [PATCH] chore(test,docs): add make test, CI, e2e container, refresh example - Makefile gains `test` and `test-e2e` targets; `deps` now installs the dev extras so pytest is available after `make deps`. - .github/workflows/test.yml runs unit tests on push and PR to main (Python 3.13 on ubuntu-latest, ignores tests/e2e by default). - tests/e2e/Containerfile + test_dotfiles_e2e.py scaffold a real container-based smoke test of `flow dotfiles init` + `link` against the example dotfiles repo. Gated by FLOW_RUN_E2E=1 so unit runs stay fast; verified locally with podman. - tests/fakes.FakeRunner uses ordered subsequence matching instead of unordered containment -- prevents accidental match between unrelated commands that happen to share tokens. - example/README.md rewritten for the current command surface (no more `dotfiles undo`, `dotfiles modules ...`, `--relink`, `bootstrap list/show/run --profile`, `bootstrap packages --resolved`). Adds an "External modules" section documenting `_module.yaml`. - example/dotfiles-repo profiles.yaml drops `allow-sudo: true` along with the runtime support. - pyproject.toml adds [tool.coverage] config. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yml | 24 +++++ Makefile | 14 ++- example/README.md | 63 +++++++++---- .../_shared/flow/.config/flow/profiles.yaml | 1 - pyproject.toml | 8 ++ tests/e2e/Containerfile | 37 ++++++++ tests/e2e/__init__.py | 1 + tests/e2e/test_dotfiles_e2e.py | 88 +++++++++++++++++++ tests/fakes.py | 15 +++- 9 files changed, 226 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/e2e/Containerfile create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_dotfiles_e2e.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b633a18 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: make deps + + - name: Run tests + run: .venv/bin/python -m pytest tests/ -v --ignore=tests/e2e diff --git a/Makefile b/Makefile index 9f7d0de..bdff51c 100644 --- a/Makefile +++ b/Makefile @@ -10,15 +10,17 @@ SPEC_FILE := flow.spec BINARY := $(DIST_DIR)/flow INSTALL_DIR ?= $(HOME)/.local/bin -.PHONY: deps build install install-local check-binary clean help +.PHONY: deps build install install-local check-binary clean help test test-e2e help: @printf "Targets:\n" - @printf " make deps Create .venv and install build dependencies\n" + @printf " make deps Create .venv and install build+dev dependencies\n" @printf " make build Build standalone binary at dist/flow\n" @printf " make install Build and install to ~/.local/bin/flow\n" @printf " make install-local Install binary to ~/.local/bin/flow\n" @printf " make check-binary Run dist/flow --help\n" + @printf " make test Run unit tests\n" + @printf " make test-e2e Run end-to-end container tests (FLOW_RUN_E2E=1)\n" @printf " make clean Remove build artifacts\n" deps: @@ -44,7 +46,7 @@ deps: fi; \ . "$(VENV_BIN)/activate"; \ python -m pip install --upgrade pip; \ - python -m pip install -e ".[build]" + python -m pip install -e ".[build,dev]" build: deps @set -eu; \ @@ -61,5 +63,11 @@ install: deps build install-local check-binary: "./$(BINARY)" --help +test: + $(VENV_PYTHON) -m pytest tests/ -q + +test-e2e: + FLOW_RUN_E2E=1 $(VENV_PYTHON) -m pytest tests/e2e/ -v + clean: rm -rf "$(BUILD_DIR)" "$(DIST_DIR)" "$(SPEC_FILE)" diff --git a/example/README.md b/example/README.md index e41f730..1e57016 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,6 @@ # Example working scenario -This folder contains a complete dotfiles + bootstrap setup for the current `flow` schema. +This folder contains a complete dotfiles + setup configuration for the current `flow` schema. ## What this example shows @@ -35,39 +35,68 @@ Initialize and link dotfiles: ```bash flow dotfiles init --repo "$EXAMPLE_REPO" flow dotfiles link --profile linux-auto -flow dotfiles undo flow dotfiles status +flow dotfiles unlink # remove all managed symlinks +flow dotfiles unlink git tmux # remove only specific packages ``` -Check repo commands: +Manage the dotfiles and any module repos as a unified set: ```bash -flow dotfiles repo status -flow dotfiles repo pull --relink --profile linux-auto -flow dotfiles repo push -flow dotfiles modules list -flow dotfiles modules sync +flow dotfiles repos list +flow dotfiles repos status +flow dotfiles repos pull +flow dotfiles repos push ``` -Edit package or file/path targets: +Edit a package or a specific file under the dotfiles repo: ```bash flow dotfiles edit git --no-commit flow dotfiles edit _shared/flow/.config/flow/profiles.yaml --no-commit ``` -Inspect bootstrap profiles and package resolution: +Inspect setup profiles and run a setup: ```bash -flow bootstrap list -flow bootstrap packages --resolved -flow bootstrap packages --profile linux-auto --resolved -flow bootstrap show linux-auto +flow setup list +flow setup show linux-auto +flow setup run linux-auto \ + --var TARGET_HOSTNAME=devbox \ + --var USER_EMAIL=you@example.com \ + --dry-run +flow setup run macos-dev --dry-run ``` -Run bootstrap dry-run: +`bootstrap` and `provision` remain as aliases for `setup`, so `flow bootstrap run linux-auto` still works. + +Install or list packages directly (independent of a setup run): ```bash -flow bootstrap run --profile linux-auto --var TARGET_HOSTNAME=devbox --var USER_EMAIL=you@example.com --dry-run -flow bootstrap run --profile macos-dev --dry-run +flow packages list +flow packages list --all +flow packages install --profile linux-auto --dry-run +flow packages install fd ripgrep --dry-run +flow packages remove docker --dry-run ``` + +## External modules + +A package directory inside the dotfiles repo can pull its contents from a separate +git repository by placing a `_module.yaml` file at the package root. Flow clones +the module into a shared cache and links from the cached path, so updates flow +through `flow dotfiles repos pull`. + +This example does NOT include a real `_module.yaml` (it would pin the example to +a flaky external dependency). For reference, a hypothetical `nvim/_module.yaml` +would look like: + +```yaml +# example/dotfiles-repo/nvim/_module.yaml (not committed -- format reference only) +source: github:your-org/nvim-config # or a full git URL +ref: + branch: main # exactly one of: branch, tag, commit +``` + +After adding such a file, the next `flow dotfiles repos pull` will clone the +module and `flow dotfiles link --profile linux-auto` will link its contents. diff --git a/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml b/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml index 57b609f..6799ec5 100644 --- a/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml +++ b/example/dotfiles-repo/_shared/flow/.config/flow/profiles.yaml @@ -12,7 +12,6 @@ profiles: - ripgrep - binary/neovim - name: docker - allow-sudo: true post-install: | sudo groupadd docker || true sudo usermod -aG docker $USER diff --git a/pyproject.toml b/pyproject.toml index 25bcd66..caff519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,11 @@ packages = ["src/flow"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.coverage.run] +source = ["src/flow"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = false diff --git a/tests/e2e/Containerfile b/tests/e2e/Containerfile new file mode 100644 index 0000000..937c599 --- /dev/null +++ b/tests/e2e/Containerfile @@ -0,0 +1,37 @@ +# Containerfile for end-to-end testing of the flow CLI. +# +# Builds a minimal Debian image with python + git, copies the flow source in, +# installs it via pip, and creates a non-root user (flow refuses to run as root). +# +# Build context expected to be the flow-cli repo root. Example: +# podman build -f tests/e2e/Containerfile -t flow-e2e . +# +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + python3 \ + python3-venv \ + python3-pip \ + sudo \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user; flow refuses to run as uid 0. +RUN useradd --create-home --shell /bin/bash flowuser \ + && echo "flowuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Copy the flow source and install it for the non-root user. +WORKDIR /opt/flow-src +COPY --chown=flowuser:flowuser . /opt/flow-src + +USER flowuser +ENV PATH="/home/flowuser/.local/bin:${PATH}" + +RUN pip3 install --user --break-system-packages -e /opt/flow-src + +WORKDIR /home/flowuser +ENTRYPOINT ["/bin/bash"] diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..a76d7e8 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""End-to-end tests gated by FLOW_RUN_E2E=1.""" diff --git a/tests/e2e/test_dotfiles_e2e.py b/tests/e2e/test_dotfiles_e2e.py new file mode 100644 index 0000000..87eb22a --- /dev/null +++ b/tests/e2e/test_dotfiles_e2e.py @@ -0,0 +1,88 @@ +"""End-to-end test for `flow dotfiles` against the example repo. + +Gated by FLOW_RUN_E2E=1 because it builds and runs a container image. +Tries podman first, falls back to docker. Skips if neither is available. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONTAINERFILE = Path(__file__).parent / "Containerfile" +EXAMPLE_REPO = REPO_ROOT / "example" / "dotfiles-repo" +IMAGE_TAG = "flow-e2e:test" + + +def _pick_runtime() -> str | None: + for binary in ("podman", "docker"): + if shutil.which(binary): + return binary + return None + + +@pytest.mark.skipif( + os.environ.get("FLOW_RUN_E2E") != "1", + reason="set FLOW_RUN_E2E=1 to run", +) +def test_dotfiles_init_and_link_in_container(): + runtime = _pick_runtime() + if runtime is None: + pytest.skip("neither podman nor docker is available") + + build = subprocess.run( + [ + runtime, "build", + "-f", str(CONTAINERFILE), + "-t", IMAGE_TAG, + str(REPO_ROOT), + ], + capture_output=True, + text=True, + ) + if build.returncode != 0: + pytest.fail(f"image build failed:\n{build.stdout}\n{build.stderr}") + + try: + # Run flow inside the container against the mounted example repo. + # `flow dotfiles init` clones, so we need a real git remote — turn + # the read-only example mount into a bare-ish working repo first. + script = ( + "set -eux; " + "cp -r /example /home/flowuser/dotfiles-src; " + "cd /home/flowuser/dotfiles-src; " + "git init -q -b main; " + "git -c user.email=e2e@example.com -c user.name=e2e add -A; " + "git -c user.email=e2e@example.com -c user.name=e2e commit -q -m initial; " + "cd /home/flowuser; " + "flow dotfiles init --repo /home/flowuser/dotfiles-src; " + "flow dotfiles link --profile linux-auto --dry-run; " + "flow dotfiles status" + ) + result = subprocess.run( + [ + runtime, "run", "--rm", + "-v", f"{EXAMPLE_REPO}:/example:ro", + IMAGE_TAG, + "-c", script, + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"e2e run failed (rc={result.returncode}):\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + finally: + subprocess.run( + [runtime, "rmi", "-f", IMAGE_TAG], + capture_output=True, + text=True, + ) diff --git a/tests/fakes.py b/tests/fakes.py index 2091bc5..d456d5a 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -11,9 +11,11 @@ from flow.core.runtime import CommandRunner class FakeRunner(CommandRunner): """CommandRunner that captures calls instead of executing. - Response matching uses keyword containment: a response keyed by - ``("ps", "{{.Names}}")`` matches any command whose argv contains - both ``"ps"`` and ``"{{.Names}}"``. + Response matching uses ordered subsequence semantics: a response keyed by + ``("ps", "{{.Names}}")`` matches any command whose argv contains those + tokens in that order (other tokens may appear before, between, or after). + This is stricter than set containment -- ``("a", "b")`` does NOT match + ``["b", "a"]`` -- and matches argv semantics more accurately. """ def __init__(self, responses: dict[tuple[str, ...], Any] | None = None): @@ -21,12 +23,17 @@ class FakeRunner(CommandRunner): self.timeouts: list[float | None] = [] self._responses: dict[tuple[str, ...], Any] = responses or {} + @staticmethod + def _match(parts: list[str], key: tuple[str, ...]) -> bool: + it = iter(parts) + return all(k in it for k in key) + def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): parts = list(argv) self.calls.append(parts) self.timeouts.append(timeout) for key, resp in self._responses.items(): - if all(k in parts for k in key): + if self._match(parts, key): return resp return subprocess.CompletedProcess(parts, 0, stdout="", stderr="")