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) <noreply@anthropic.com>
This commit is contained in:
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@@ -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
|
||||
14
Makefile
14
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)"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,6 @@ profiles:
|
||||
- ripgrep
|
||||
- binary/neovim
|
||||
- name: docker
|
||||
allow-sudo: true
|
||||
post-install: |
|
||||
sudo groupadd docker || true
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
@@ -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
|
||||
|
||||
37
tests/e2e/Containerfile
Normal file
37
tests/e2e/Containerfile
Normal file
@@ -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"]
|
||||
1
tests/e2e/__init__.py
Normal file
1
tests/e2e/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""End-to-end tests gated by FLOW_RUN_E2E=1."""
|
||||
88
tests/e2e/test_dotfiles_e2e.py
Normal file
88
tests/e2e/test_dotfiles_e2e.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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="")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user