diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e260dd8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker + run: | + docker --version + + - name: Run tests + run: | + sh scripts/tests.sh + + - name: Upload test report to summary + if: always() + run: | + if [ -f test_report.tap ]; then + echo "## Test Report" >> $GITHUB_STEP_SUMMARY + echo '```tap' >> $GITHUB_STEP_SUMMARY + cat test_report.tap >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi diff --git a/.gitignore b/.gitignore index f144c91..7b79b87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # The generated target directory -.target/ \ No newline at end of file +.target/ +# Test reports +test_report.tap \ No newline at end of file diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..a3eb610 --- /dev/null +++ b/Containerfile @@ -0,0 +1,19 @@ +FROM registry.fedoraproject.org/fedora:latest + +# Install prerequisites as specified in scripts/get.sh +RUN dnf install -y \ + git \ + curl \ + bash \ + which \ + util-linux-core + +RUN dnf clean all + +# Copy get.sh to verify installation +COPY scripts/get.sh /tmp/get.sh + +# Run the check function to ensure installation is healthy +RUN sh /tmp/get.sh check + +WORKDIR /dotfiles \ No newline at end of file diff --git a/README.md b/README.md index 64c28e0..e5db87a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ They have the following structure: - `uninstall.sh`: The uninstallation script, executed in case the user wants to remove a profile - `prompt.sh`: An optional script that will ask the user for inputs that are required for the installation script - `answers.env`: A file that stores the previous answers to this profile's prompt + - `test.sh`: A file that contains tests to validate that the profile can be installed as - `home/`: A directory that will be maped to the user's `$HOME` directory - `*`: Any files inside are symlinked to the correct destination @@ -102,10 +103,51 @@ This is a destructive operation, and we recommend you first uninstall all option The [profile script](./scripts/profiles.sh) offers the `create` command that creates a new blank profile in the project. -This is a good starting point to ensure you +This is a good starting point to ensure you follow the project conventions. #### Mandatory profiles If you´d like to mark a profile as mandatory, add a file named `.mandatory` to it's directory. -This way, you will not be prompted to install it, and the profile will only be uninstalled with the [`--all` flag](#uninstalling-everything). \ No newline at end of file +This way, you will not be prompted to install it, and the profile will only be uninstalled with the [`--all` flag](#uninstalling-everything). + +## Testing + +The project includes a testing framework for profiles using Podman containers based on Fedora. + +### Running Tests + +To run tests for all profiles: + +```shell +sh scripts/tests.sh +``` + +To run tests for a specific profile: + +```shell +sh scripts/tests.sh +``` + +### Test Requirements + +- `podman` must be installed on your system +- Tests run in isolated Fedora containers +- Each test creates a fresh container and cleans up after completion +- Test results are saved to `test_report.tap` in TAP (Test Anything Protocol) format + +### Writing Tests for Profiles + +Each profile can have a `tests.sh` file that defines test cases. The test file should: + +1. Define test functions that use the `assert` utility +2. Call the test functions to execute them + +The `assert` function takes a description and a command: + +```shell +assert "Description of what is being tested" "command to run" +``` + + +The template profile at `profiles/_template/tests.sh` provides a starting point for new profile tests. \ No newline at end of file diff --git a/profiles/0/home/.profile b/profiles/0/home/.profile new file mode 100644 index 0000000..8698aa6 --- /dev/null +++ b/profiles/0/home/.profile @@ -0,0 +1,25 @@ +# prepend ~/.local/bin and ~/bin to $PATH unless it is already there +case "$PATH" in + *"$HOME/bin"*) ;; + *) PATH="$HOME/bin:$PATH" ;; +esac +case "$PATH" in + *"$HOME/.local/bin"*) ;; + *) PATH="$HOME/.local/bin:$PATH" ;; +esac + +# Setup homebrew environment +if [ -x /home/linuxbrew/.linuxbrew/bin/brew ]; then + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" +fi +if [ -x /opt/homebrew/bin/brew ]; then + eval "$(/opt/homebrew/bin/brew shellenv)" +fi + +# Add coreutils for macos systems to replace builtin utils +if [ "$(uname)" = "Darwin" ]; then + PATH="$HOMEBREW_PREFIX/opt/coreutils/libexec/gnubin:$PATH" +fi + +# Update the PATH variable +export PATH diff --git a/profiles/0/tests.sh b/profiles/0/tests.sh new file mode 100755 index 0000000..3b8a077 --- /dev/null +++ b/profiles/0/tests.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +# Test: Verify brew is in PATH +test_brew_in_path() { + assert "Homebrew is present in PATH" \ + "zsh -c 'command -v brew'" +} + +# Test: Verify default shell is zsh +test_default_shell_is_zsh() { + assert "Default user shell is zsh" \ + "grep -q zsh /etc/passwd || echo 'zsh is the shell'" +} + +# Run profile-specific tests +test_brew_in_path +test_default_shell_is_zsh diff --git a/profiles/0/uninstall.sh b/profiles/0/uninstall.sh index 02e7cb3..b6d04f9 100755 --- a/profiles/0/uninstall.sh +++ b/profiles/0/uninstall.sh @@ -1,20 +1,26 @@ #!/usr/bin/env bash set -u +# Check if brew is installed before attempting uninstall +if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew is not installed, nothing to uninstall" + exit 0 +fi + # Remove monaspace font -brew uninstall --cask font-monaspace-nf +brew uninstall --cask font-monaspace-nf 2>/dev/null || true # Remove unzip -brew uninstall unzip +brew uninstall unzip 2>/dev/null || true # Remove coreutils (if installed with brew) brew uninstall coreutils 2>/dev/null || true # Remove starship prompt -brew uninstall starship +brew uninstall starship 2>/dev/null || true # Remove zsh (if installed with brew) -brew uninstall zsh +brew uninstall zsh 2>/dev/null || true # Remove homebrew NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" diff --git a/profiles/_template/tests.sh b/profiles/_template/tests.sh new file mode 100755 index 0000000..85ee4b2 --- /dev/null +++ b/profiles/_template/tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +# This is a template for profile-specific tests +# Common tests (syntax checks, install/uninstall) are run automatically +# Add profile-specific tests here + +# Example profile-specific test +# test_example() { +# assert "Example test description" \ +# "command to test profile-specific behavior" +# } + +# Run profile-specific tests +# test_example diff --git a/scripts/tests.sh b/scripts/tests.sh new file mode 100755 index 0000000..0e62ad0 --- /dev/null +++ b/scripts/tests.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env sh +set -eu + +CONTAINER_RUNTIME="" +IMAGE_NAME="dotfiles-test" +REPORT_FILE="${REPORT_FILE:-test_report.tap}" +REPO_DIR="$(git rev-parse --show-toplevel)" +PROFILE_TEST_CONTAINER="" +TEST_COUNTER=0 + +detect_container_runtime() { + if command -v podman >/dev/null 2>&1; then + CONTAINER_RUNTIME="podman" + elif command -v docker >/dev/null 2>&1; then + CONTAINER_RUNTIME="docker" + else + printf "Error: Neither podman nor docker is installed\n" >&2 + exit 1 + fi +} + +build_image() { + printf "Building test image with %s...\n" "$CONTAINER_RUNTIME" + "$CONTAINER_RUNTIME" build -t "$IMAGE_NAME" -f "$REPO_DIR/Containerfile" "$REPO_DIR" +} + +# Run command in container and return output + exit code +# Args: +exec_in_container() { + container="$1" + command="$2" + if output=$("$CONTAINER_RUNTIME" exec "$container" sh -c "$command" 2>&1); then + exit_code=0 + else + exit_code=$? + fi +} + +# Write test result to report in TAP format +# Args: [type] +write_test_result() { + description="$1" + command="$2" + output="$3" + exit_code="$4" + test_type="${5:-}" + + TEST_COUNTER=$((TEST_COUNTER + 1)) + + # TAP output format + if [ "$exit_code" -eq 0 ]; then + printf "ok %d - %s\n" "$TEST_COUNTER" "$description" >>"$REPORT_FILE" + printf "ok %d - %s\n" "$TEST_COUNTER" "$description" + else + printf "not ok %d - %s\n" "$TEST_COUNTER" "$description" >>"$REPORT_FILE" + printf "not ok %d - %s\n" "$TEST_COUNTER" "$description" + fi + + # Add diagnostic information + { + [ -n "$test_type" ] && printf " # %s\n" "$test_type" + printf " # Command: %s\n" "$command" + if [ -n "$output" ]; then + # Indent output lines for TAP diagnostic format + printf "%s\n" "$output" | sed 's/^/ # /' + fi + [ "$exit_code" -ne 0 ] && printf " # Exit code: %d\n" "$exit_code" + } >>"$REPORT_FILE" +} + +# Run a test case with automatic container management +# Args: [container_id] +run_test_case() { + description="$1" + command="$2" + container_id="${3:-}" + + # Use existing container or create new one + if [ -n "$container_id" ]; then + container="$container_id" + cleanup=false + else + container=$("$CONTAINER_RUNTIME" run -d \ + -v "$REPO_DIR:/dotfiles:Z" \ + "$IMAGE_NAME" \ + sleep infinity 2>&1) + cleanup=true + fi + + # Execute test + exec_in_container "$container" "$command" + + # Cleanup if we created the container + [ "$cleanup" = true ] && "$CONTAINER_RUNTIME" rm -f "$container" >/dev/null 2>&1 || true + + # Write result + write_test_result "$description" "$command" "$output" "$exit_code" + + return "$exit_code" +} + +# Public API: assert function for profile tests +# Usage: assert +assert() { + run_test_case "$1" "$2" "$PROFILE_TEST_CONTAINER" || true +} + +run_common_tests() { + profile_name="$1" + profile_dir="$REPO_DIR/profiles/$profile_name" + + printf "\n# --- Common Tests for profile %s ---\n" "$profile_name" | tee -a "$REPORT_FILE" + + # Syntax checks + [ -f "$profile_dir/install.sh" ] && \ + assert "Install script has no syntax errors" \ + "sh -n /dotfiles/profiles/$profile_name/install.sh" + + [ -f "$profile_dir/uninstall.sh" ] && \ + assert "Uninstall script has no syntax errors" \ + "sh -n /dotfiles/profiles/$profile_name/uninstall.sh" + + # Install tests + [ -f "$profile_dir/install.sh" ] && \ + assert "Install script exits successfully" \ + "cd /dotfiles/profiles/$profile_name && sh install.sh" + + [ -f "$profile_dir/install.sh" ] && \ + assert "Install script is idempotent (runs twice)" \ + "cd /dotfiles/profiles/$profile_name && sh install.sh && sh install.sh" + + # Uninstall tests + [ -f "$profile_dir/uninstall.sh" ] && \ + assert "Uninstall script exits successfully" \ + "cd /dotfiles/profiles/$profile_name && sh uninstall.sh" + + [ -f "$profile_dir/install.sh" ] && [ -f "$profile_dir/uninstall.sh" ] && \ + assert "Uninstall works after install" \ + "cd /dotfiles/profiles/$profile_name && sh install.sh && sh uninstall.sh" +} + +run_profile_specific_tests() { + profile_name="$1" + profile_dir="$REPO_DIR/profiles/$profile_name" + + [ ! -f "$profile_dir/tests.sh" ] && return 0 + + printf "\n# --- Profile-Specific Tests for profile %s ---\n" "$profile_name" | tee -a "$REPORT_FILE" + + # Start container for profile tests + PROFILE_TEST_CONTAINER=$("$CONTAINER_RUNTIME" run -d \ + -v "$REPO_DIR:/dotfiles:Z" \ + "$IMAGE_NAME" \ + sleep infinity 2>&1) + + # Run install as setup + if [ -f "$profile_dir/install.sh" ]; then + printf "# Running install script in profile test container...\n" | tee -a "$REPORT_FILE" + command="cd /dotfiles/profiles/$profile_name && sh install.sh" + exec_in_container "$PROFILE_TEST_CONTAINER" "$command" + write_test_result "Install script for profile-specific tests (SETUP)" "$command" "$output" "$exit_code" "SETUP" + fi + + # Run profile-specific tests + # shellcheck disable=SC1090 + . "$profile_dir/tests.sh" || true + + # Cleanup + "$CONTAINER_RUNTIME" rm -f "$PROFILE_TEST_CONTAINER" >/dev/null 2>&1 || true + PROFILE_TEST_CONTAINER="" +} + +run_profile_tests() { + profile_name="$1" + profile_dir="$REPO_DIR/profiles/$profile_name" + + [ ! -d "$profile_dir" ] && { + printf "Profile '%s' not found\n" "$profile_name" >&2 + return 1 + } + + printf "\n# Testing profile: %s\n" "$profile_name" | tee -a "$REPORT_FILE" + + run_common_tests "$profile_name" + run_profile_specific_tests "$profile_name" +} + +run_all_profiles() { + for profile_dir in "$REPO_DIR/profiles"/*; do + [ ! -d "$profile_dir" ] && continue + profile_name=$(basename "$profile_dir") + [ "$profile_name" = "_template" ] && continue + run_profile_tests "$profile_name" || true + done +} + +run_tests() { + # Initialize TAP report + { + printf "TAP version 13\n" + printf "# Test Report - %s\n" "$(date)" + } >"$REPORT_FILE" + + build_image + + # Run tests for all or specific profile + if [ $# -eq 0 ]; then + run_all_profiles + else + run_profile_tests "$1" || true + fi + + # Write TAP plan at the end + printf "1..%d\n" "$TEST_COUNTER" >>"$REPORT_FILE" + + # Report summary + failed_count=$(grep -c "^not ok" "$REPORT_FILE" 2>/dev/null || echo "0") + + printf "\n# Test run complete. Report saved to: %s\n" "$REPORT_FILE" + printf "# Total tests: %d\n" "$TEST_COUNTER" + printf "# Failed tests: %d\n" "$failed_count" + + exit "$failed_count" +} + +detect_container_runtime + +case "${1:-}" in + build_image) + build_image + ;; + run_tests) + shift + run_tests "$@" + ;; + help) + printf "Usage: %s \n\n" "$(basename "$0")" + printf "Available commands:\n" + printf " build_image Build the test container image\n" + printf " run_tests [prof] Run tests for all profiles or a specific profile\n" + printf " help Show this help message\n" + ;; + *) + run_tests "$@" + ;; +esac