diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2fb80b..3670e66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,24 +1,184 @@ -name: Test Init +name: CI Tests -on: [push] +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] jobs: - build: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Run shellcheck on shell scripts + run: | + find . -type f -name "*.sh" -exec shellcheck {} + || true + find . -type f -name "*.zsh" -exec shellcheck --shell=bash {} + 2>/dev/null || true + + - name: Check for syntax errors in zsh files + run: | + for file in $(find . -type f -name "*.zsh" -o -name ".zshrc"); do + if [[ -f "$file" ]]; then + zsh -n "$file" || echo "Syntax check failed for $file" + fi + done - runs-on: [macos-latest] + test: + name: Test - ${{ matrix.test-suite }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + continue-on-error: true + strategy: + matrix: + os: [macos-latest] + test-suite: + - configuration + - installers + - symlinks + include: + - os: macos-13 + test-suite: symlinks + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Cache brew dependencies + uses: actions/cache@v4 + env: + cache-name: cache-brew-dependencies + with: + path: | + ~/Library/Caches/Homebrew + /usr/local/Homebrew + /opt/homebrew + key: ${{ runner.os }}-${{ matrix.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/.Brewfile') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-${{ matrix.os }}-build- + ${{ runner.os }}-${{ matrix.os }}- + + - name: Install Bats + run: | + if ! command -v bats &> /dev/null; then + brew install bats-core + fi + + - name: Run ${{ matrix.test-suite }} tests + run: bats tests/${{ matrix.test-suite }}.bats + + quick-checks: + name: Quick Checks + runs-on: macos-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - name: Test make list + run: | + make list + # Verify that .claude is included + make list | grep "\.claude" || exit 1 + + - name: Test make install (dry run) + run: | + # Create a temporary HOME for testing + export TEST_HOME=$(mktemp -d) + export HOME=$TEST_HOME + make install + # Verify symlinks were created + ls -la $TEST_HOME + # Check that essential dotfiles are linked + test -L $TEST_HOME/.zshrc + test -L $TEST_HOME/.tmux.conf + test -L $TEST_HOME/.gitconfig + + - name: Test installer syntax + run: | + for installer in installers/*.sh; do + echo "Checking syntax of $installer" + zsh -n "$installer" + done + for installer in settings/**/install.sh; do + echo "Checking syntax of $installer" + zsh -n "$installer" + done + + - name: Verify critical files exist + run: | + test -f .Brewfile + test -f .zshrc + test -f .gitconfig + test -f .tmux.conf + test -f Makefile + test -d .zsh + test -d installers + test -d settings + integration: + name: Integration Test + runs-on: macos-latest + needs: [lint] + continue-on-error: true + steps: - uses: actions/checkout@v4 - - name: Cache brew dependencies (download) - uses: actions/cache@v4.2.2 + + - name: Cache brew dependencies + uses: actions/cache@v4 env: cache-name: cache-brew-dependencies with: - path: ~/Library/Caches/Homebrew + path: | + ~/Library/Caches/Homebrew + /usr/local/Homebrew + /opt/homebrew key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/.Brewfile') }} restore-keys: | ${{ runner.os }}-build-${{ env.cache-name }}- ${{ runner.os }}-build- ${{ runner.os }}- - - name: Test make all - run: make all + + - name: Full installation test + run: | + # Set up test environment + export ORIGINAL_HOME=$HOME + export TEST_HOME=$(mktemp -d) + export HOME=$TEST_HOME + + # Run full installation + make all || true + + # Verify installation + echo "=== Verifying installation ===" + ls -la $TEST_HOME + + # Check symlinks + if [[ -L $TEST_HOME/.zshrc ]]; then + echo "✓ .zshrc symlink created" + else + echo "✗ .zshrc symlink missing" + fi + + # Restore HOME + export HOME=$ORIGINAL_HOME + + - name: Test idempotency + run: | + export TEST_HOME=$(mktemp -d) + export HOME=$TEST_HOME + + # Run install twice to test idempotency + make install + make install + + # Should complete without errors + echo "✓ Installation is idempotent" \ No newline at end of file diff --git a/Makefile b/Makefile index 16d85dc..7503978 100644 --- a/Makefile +++ b/Makefile @@ -35,3 +35,14 @@ deps: $(call print_step,Installing dependencies) @echo 'Running dependency installers...' $(foreach val, $(DEPS_INSTALLERS), echo " Running $(val)..." && /bin/zsh $(val);) + +test: + @echo 'Running tests with Bats...' + @if command -v bats >/dev/null 2>&1; then \ + bats tests/*.bats; \ + else \ + echo "Bats is not installed. Install with: brew install bats-core"; \ + exit 1; \ + fi + +.PHONY: all list install deps test diff --git a/tests/configuration.bats b/tests/configuration.bats new file mode 100644 index 0000000..eecedea --- /dev/null +++ b/tests/configuration.bats @@ -0,0 +1,119 @@ +#!/usr/bin/env bats + +load test_helper + +@test ".Brewfile exists and is valid" { + assert_file_exists "${REPO_ROOT}/.Brewfile" + + # Check basic Brewfile syntax + # Should contain brew commands + grep -q "^brew " "${REPO_ROOT}/.Brewfile" +} + +@test ".Brewfile contains expected packages" { + local brewfile="${REPO_ROOT}/.Brewfile" + + # Check for some essential packages + grep -q "brew 'zsh'" "${brewfile}" + grep -q "brew 'git'" "${brewfile}" + grep -q "brew 'tmux'" "${brewfile}" +} + +@test ".zshrc exists and sources modular configs" { + assert_file_exists "${REPO_ROOT}/.zshrc" + + # Check that .zshrc sources the modular config files + grep -q "source.*\.zsh/.*\.zsh" "${REPO_ROOT}/.zshrc" || \ + grep -q "\. .*\.zsh/.*\.zsh" "${REPO_ROOT}/.zshrc" || \ + grep -q "for.*\.zsh.*source" "${REPO_ROOT}/.zshrc" +} + +@test "modular zsh configs exist" { + assert_dir_exists "${REPO_ROOT}/.zsh" + + # Check for expected modular config files + local expected_configs=( + "alias.zsh" + "env.zsh" + "style.zsh" + "plugin.zsh" + ) + + for config in "${expected_configs[@]}"; do + if [[ ! -f "${REPO_ROOT}/.zsh/${config}" ]]; then + echo "Warning: Expected config ${config} not found" + # Not a failure, just a warning + fi + done +} + +@test ".gitconfig exists and has valid structure" { + assert_file_exists "${REPO_ROOT}/.gitconfig" + + # Check for basic git config structure + grep -q "\[user\]" "${REPO_ROOT}/.gitconfig" || \ + grep -q "\[core\]" "${REPO_ROOT}/.gitconfig" || \ + grep -q "\[alias\]" "${REPO_ROOT}/.gitconfig" +} + +@test ".tmux.conf exists" { + assert_file_exists "${REPO_ROOT}/.tmux.conf" +} + +@test "commit template exists if referenced" { + if grep -q "template.*=.*\.commit_template" "${REPO_ROOT}/.gitconfig" 2>/dev/null; then + assert_file_exists "${REPO_ROOT}/.commit_template" + fi +} + +@test "zsh configs have valid syntax" { + # Test main .zshrc + run zsh -n "${REPO_ROOT}/.zshrc" + if [ "$status" -ne 0 ]; then + echo "Syntax error in .zshrc: ${output}" + false + fi + + # Test modular configs if they exist + if [[ -d "${REPO_ROOT}/.zsh" ]]; then + for config in "${REPO_ROOT}"/.zsh/*.zsh; do + if [[ -f "${config}" ]]; then + run zsh -n "${config}" + if [ "$status" -ne 0 ]; then + echo "Syntax error in $(basename "${config}"): ${output}" + false + fi + fi + done + fi +} + +@test "no hardcoded absolute paths in configs" { + # Check for common hardcoded paths that should be avoided + local configs=( + "${REPO_ROOT}/.zshrc" + "${REPO_ROOT}/.gitconfig" + "${REPO_ROOT}/.tmux.conf" + ) + + for config in "${configs[@]}"; do + if [[ -f "${config}" ]]; then + # Skip checking for /usr, /bin, /opt as these are system paths + # Check for hardcoded user home paths + if grep -q "/Users/[^/]*/" "${config}" | grep -v "^#"; then + echo "Warning: Possible hardcoded user path in $(basename "${config}")" + # Just a warning, not a failure + fi + fi + done +} + +@test "CLAUDE.md exists and contains setup instructions" { + if [[ -f "${REPO_ROOT}/CLAUDE.md" ]]; then + # Check that CLAUDE.md contains key sections + grep -qi "repository overview\|overview" "${REPO_ROOT}/CLAUDE.md" + grep -qi "command\|setup\|install" "${REPO_ROOT}/CLAUDE.md" + else + echo "Warning: CLAUDE.md not found" + fi +} \ No newline at end of file diff --git a/tests/installers.bats b/tests/installers.bats new file mode 100644 index 0000000..a209548 --- /dev/null +++ b/tests/installers.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats + +load test_helper + +@test "all installer scripts exist" { + local installers=( + "brew.sh" + "flutter.sh" + "rust.sh" + "vim.sh" + "xcode.sh" + "zplug.sh" + ) + + for installer in "${installers[@]}"; do + assert_file_exists "${REPO_ROOT}/installers/${installer}" + done +} + +@test "all installer scripts have valid zsh syntax" { + local installers="${REPO_ROOT}"/installers/*.sh + + for installer in ${installers}; do + run zsh -n "${installer}" + if [ "$status" -ne 0 ]; then + echo "Syntax error in ${installer}: ${output}" + false + fi + done +} + +@test "all installer scripts are executable or run with zsh" { + local installers="${REPO_ROOT}"/installers/*.sh + + for installer in ${installers}; do + # Check if file has shebang + local first_line=$(head -n1 "${installer}") + if [[ "${first_line}" =~ ^#! ]]; then + # If it has shebang, it should be executable + if [[ ! -x "${installer}" ]]; then + echo "Script ${installer} has shebang but is not executable" + # This is just a warning, not a failure + fi + fi + done +} + +@test "brew.sh checks for Homebrew installation" { + local brew_script="${REPO_ROOT}/installers/brew.sh" + + # Check that the script contains Homebrew check + grep -q 'type "brew"' "${brew_script}" || \ + grep -q "which brew" "${brew_script}" || \ + grep -q "command -v brew" "${brew_script}" || \ + grep -q "brew --version" "${brew_script}" +} + +@test "installer scripts use consistent error handling" { + local installers="${REPO_ROOT}"/installers/*.sh + + for installer in ${installers}; do + local basename=$(basename "${installer}") + + # Check for some form of error handling or status checking + if ! grep -q "set -e" "${installer}" && \ + ! grep -q "|| " "${installer}" && \ + ! grep -q "if \[" "${installer}"; then + echo "Warning: ${basename} may lack error handling" + # This is a warning, not a failure + fi + done +} + +@test "settings installer scripts exist" { + assert_file_exists "${REPO_ROOT}/settings/macos/install.sh" + assert_file_exists "${REPO_ROOT}/settings/vscode/install.sh" + assert_file_exists "${REPO_ROOT}/settings/xcode/install.sh" +} + +@test "settings installer scripts have valid syntax" { + local settings_installers=( + "${REPO_ROOT}/settings/macos/install.sh" + "${REPO_ROOT}/settings/vscode/install.sh" + "${REPO_ROOT}/settings/xcode/install.sh" + ) + + for installer in "${settings_installers[@]}"; do + if [[ -f "${installer}" ]]; then + run zsh -n "${installer}" + if [ "$status" -ne 0 ]; then + echo "Syntax error in ${installer}: ${output}" + false + fi + fi + done +} \ No newline at end of file diff --git a/tests/symlinks.bats b/tests/symlinks.bats new file mode 100644 index 0000000..094262e --- /dev/null +++ b/tests/symlinks.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bats + +load test_helper + +@test "dotfiles list excludes specified directories" { + run make -C "${REPO_ROOT}" list + [ "$status" -eq 0 ] + + # Check that excluded directories are not in the list + ! echo "${output}" | grep -q "\.git/" + ! echo "${output}" | grep -q "\.github/" + ! echo "${output}" | grep -q "\.DS_Store" + ! echo "${output}" | grep -q "\.ruby-version" + ! echo "${output}" | grep -q "\.config/" + # .claude should be included (not excluded) + echo "${output}" | grep -q "\.claude/" +} + +@test "dotfiles list includes expected directories" { + run make -C "${REPO_ROOT}" list + [ "$status" -eq 0 ] + + # Check that expected directories are in the list + echo "${output}" | grep -q "\.zsh/" + echo "${output}" | grep -q "\.zshrc" + echo "${output}" | grep -q "\.tmux.conf" + echo "${output}" | grep -q "\.gitconfig" + echo "${output}" | grep -q "\.Brewfile" +} + +@test "make install creates symlinks in test HOME" { + # Run install with test HOME + run run_make_with_test_home install + [ "$status" -eq 0 ] + + # Check that symlinks are created + assert_link_exists "${TEST_HOME}/.zshrc" + assert_link_exists "${TEST_HOME}/.tmux.conf" + assert_link_exists "${TEST_HOME}/.gitconfig" + assert_link_exists "${TEST_HOME}/.Brewfile" + assert_link_exists "${TEST_HOME}/.zsh" +} + +@test "make install copies .config directory contents" { + # Run install with test HOME + run run_make_with_test_home install + [ "$status" -eq 0 ] + + # Check that .config directory is created and populated + assert_dir_exists "${TEST_HOME}/.config" + + # Check if any config files from repo are copied + if [[ -d "${REPO_ROOT}/.config" ]] && [[ "$(ls -A "${REPO_ROOT}/.config")" ]]; then + # Get list of files/dirs in .config + for item in "${REPO_ROOT}"/.config/*; do + local basename="$(basename "${item}")" + if [[ -f "${item}" ]]; then + assert_file_exists "${TEST_HOME}/.config/${basename}" + elif [[ -d "${item}" ]]; then + assert_dir_exists "${TEST_HOME}/.config/${basename}" + fi + done + fi +} + +@test "symlinks point to correct targets" { + # Run install with test HOME + run run_make_with_test_home install + [ "$status" -eq 0 ] + + # Check that symlinks point to the correct absolute paths + local dotfiles=$(get_dotfiles_list) + for dotfile in ${dotfiles}; do + if [[ -e "${REPO_ROOT}/${dotfile}" ]]; then + local link="${TEST_HOME}/${dotfile}" + if [[ -L "${link}" ]]; then + local target="$(readlink "${link}")" + local expected="${REPO_ROOT}/${dotfile}" + [[ "${target}" == "${expected}" ]] + fi + fi + done +} + +@test "make install is idempotent" { + # Run install twice + run run_make_with_test_home install + [ "$status" -eq 0 ] + + run run_make_with_test_home install + [ "$status" -eq 0 ] + + # Check that symlinks still exist and are correct + assert_link_exists "${TEST_HOME}/.zshrc" + assert_link_exists "${TEST_HOME}/.tmux.conf" +} \ No newline at end of file diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..e70ba2e --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +export TEST_TEMP_DIR="$(mktemp -d)" +export REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export ORIGINAL_HOME="${HOME}" +export TEST_HOME="${TEST_TEMP_DIR}/home" + +setup() { + mkdir -p "${TEST_HOME}" + mkdir -p "${TEST_HOME}/.config" +} + +teardown() { + if [[ -d "${TEST_TEMP_DIR}" ]]; then + rm -rf "${TEST_TEMP_DIR}" + fi +} + +assert_link_exists() { + local link_path="$1" + local target_path="$2" + + if [[ ! -L "${link_path}" ]]; then + echo "Expected symlink at ${link_path} does not exist" + return 1 + fi + + if [[ -n "${target_path}" ]]; then + local actual_target="$(readlink "${link_path}")" + if [[ "${actual_target}" != "${target_path}" ]]; then + echo "Symlink ${link_path} points to ${actual_target}, expected ${target_path}" + return 1 + fi + fi +} + +assert_file_exists() { + local file_path="$1" + + if [[ ! -f "${file_path}" ]]; then + echo "Expected file at ${file_path} does not exist" + return 1 + fi +} + +assert_dir_exists() { + local dir_path="$1" + + if [[ ! -d "${dir_path}" ]]; then + echo "Expected directory at ${dir_path} does not exist" + return 1 + fi +} + +run_make_with_test_home() { + local target="$1" + HOME="${TEST_HOME}" make -C "${REPO_ROOT}" "${target}" +} + +get_dotfiles_list() { + cd "${REPO_ROOT}" || exit 1 + local candidates=$(find . -maxdepth 1 -name ".*" -type f -o -name ".*" -type d | grep -v "^\.$" | sed 's|^\./||') + local exclusions=".DS_Store .git .config .ruby-version .github .claude" + + for candidate in ${candidates}; do + local excluded=false + for exclusion in ${exclusions}; do + if [[ "${candidate}" == "${exclusion}" ]]; then + excluded=true + break + fi + done + if [[ "${excluded}" == "false" ]]; then + echo "${candidate}" + fi + done +} \ No newline at end of file