diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4cf0a4e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + name: BATS Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install bats-core (Linux) + if: runner.os == 'Linux' + run: sudo apt-get install -y bats + + - name: Install bats-core (macOS) + if: runner.os == 'macOS' + run: brew install bats-core + + - name: Install bats helper libraries + run: | + git clone --depth 1 https://github.com/bats-core/bats-support.git \ + tests/test_helper/bats-support + git clone --depth 1 https://github.com/bats-core/bats-assert.git \ + tests/test_helper/bats-assert + + - name: Make scripts and mocks executable + run: | + chmod +x install.sh uninstall.sh shell_hook.sh post-checkout + chmod +x tests/test_helper/mocks/* + + - name: Run tests + run: bats --tap tests/*.bats diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a89540b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# bats helper libraries (cloned at CI time, not committed) +tests/test_helper/bats-support/ +tests/test_helper/bats-assert/ + +.DS_Store \ No newline at end of file diff --git a/install.sh b/install.sh index db01bde..fd90ff7 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,7 @@ TOOL_NAME="Luca" BIN_NAME="luca" -INSTALL_DIR="/usr/local/bin" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" TOOL_FOLDER=".luca" VERSION_FILE="${PWD}/.luca-version" ORGANIZATION="LucaTools" diff --git a/tests/fixtures/Lucafile b/tests/fixtures/Lucafile new file mode 100644 index 0000000..bc1b62d --- /dev/null +++ b/tests/fixtures/Lucafile @@ -0,0 +1,3 @@ +tools: + - name: mytool + version: "1.0.0" diff --git a/tests/fixtures/api_response_latest.json b/tests/fixtures/api_response_latest.json new file mode 100644 index 0000000..9fd4731 --- /dev/null +++ b/tests/fixtures/api_response_latest.json @@ -0,0 +1 @@ +{"tag_name": "v2.0.0", "name": "Luca v2.0.0"} diff --git a/tests/fixtures/bashrc_with_hook b/tests/fixtures/bashrc_with_hook new file mode 100644 index 0000000..b963410 --- /dev/null +++ b/tests/fixtures/bashrc_with_hook @@ -0,0 +1,4 @@ +export PATH="$PATH:/usr/local/bin" + +# Initialize Luca shell hook (added by Luca installer) +[[ -s "$HOME/.luca/shell_hook.sh" ]] && source "$HOME/.luca/shell_hook.sh" diff --git a/tests/install.bats b/tests/install.bats new file mode 100644 index 0000000..d1720bf --- /dev/null +++ b/tests/install.bats @@ -0,0 +1,230 @@ +#!/usr/bin/env bats + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/common' + +setup() { + common_setup +} + +# --------------------------------------------------------------------------- +# Version detection +# --------------------------------------------------------------------------- + +@test "version: reads from .luca-version file" { + echo "v1.2.3" > "$BATS_TEST_TMPDIR/.luca-version" + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Using version from" + assert_output --partial "Target version: v1.2.3" +} + +@test "version: fetches latest from GitHub API when no version file" { + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Missing" + assert_output --partial "Fetching the latest version" + assert_output --partial "v2.0.0" +} + +@test "version: API returns empty object exits with error" { + export MOCK_CURL_BEHAVIOR=api_empty + + run bash "$REPO_ROOT/install.sh" + + assert_failure + assert_output --partial "ERROR: Could not fetch latest version" +} + +@test "version: invalid semver in version file exits with error" { + echo "not-a-version" > "$BATS_TEST_TMPDIR/.luca-version" + + run bash "$REPO_ROOT/install.sh" + + assert_failure + assert_output --partial "Invalid version format" +} + +@test "version: valid semver without v prefix is accepted" { + echo "1.2.3" > "$BATS_TEST_TMPDIR/.luca-version" + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Target version: 1.2.3" +} + +@test "version: valid semver with prerelease tag is accepted" { + echo "v1.2.3-beta.1" > "$BATS_TEST_TMPDIR/.luca-version" + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Target version: v1.2.3-beta.1" +} + +@test "version: GITHUB_TOKEN set prints authenticated request message" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + + run env GITHUB_TOKEN=mytoken bash "$REPO_ROOT/install.sh" + + assert_output --partial "Using GITHUB_TOKEN for authenticated GitHub API requests" +} + +# --------------------------------------------------------------------------- +# OS detection +# --------------------------------------------------------------------------- + +@test "os: Darwin detected as macOS" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + # MOCK_UNAME_OUTPUT defaults to Darwin + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Detected macOS" +} + +@test "os: Linux detected via /etc/os-release" { + if [ ! -f /etc/os-release ]; then + skip "Requires /etc/os-release (Linux only)" + fi + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_UNAME_OUTPUT="Linux" + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Detected Linux" +} + +# --------------------------------------------------------------------------- +# Already up to date +# --------------------------------------------------------------------------- + +@test "skip: already up-to-date version exits 0 without downloading" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_LUCA_VERSION="v2.0.0" + # Place a fake luca binary that returns v2.0.0 + cp "$TESTS_DIR/test_helper/mocks/luca" "$TEST_INSTALL_DIR/luca" + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "already up to date" + # Verify curl was NOT called for a download + refute_output --partial "Downloading" +} + +@test "skip: different installed version proceeds with update" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_LUCA_VERSION="v1.0.0" + cp "$TESTS_DIR/test_helper/mocks/luca" "$TEST_INSTALL_DIR/luca" + + run bash "$REPO_ROOT/install.sh" + + assert_output --partial "Updating to version" +} + +# --------------------------------------------------------------------------- +# Download and install +# --------------------------------------------------------------------------- + +@test "install: happy path installs binary to INSTALL_DIR" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "successfully installed" + assert [ -f "$TEST_INSTALL_DIR/luca" ] +} + +@test "install: curl download failure exits with error" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_CURL_BEHAVIOR=download_fail + + run bash "$REPO_ROOT/install.sh" + + assert_failure + assert_output --partial "ERROR: Could not download" +} + +@test "install: unzip failure exits with error and cleans up zip" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_UNZIP_BEHAVIOR=fail + + run bash "$REPO_ROOT/install.sh" + + assert_failure + assert_output --partial "ERROR: Failed to extract" + # Zip file should have been cleaned up + assert [ ! -f "$BATS_TEST_TMPDIR/Luca-macOS.zip" ] +} + +# --------------------------------------------------------------------------- +# Shell hook setup +# --------------------------------------------------------------------------- + +@test "shell_hook: download failure is non-fatal (exits 0)" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + export MOCK_CURL_BEHAVIOR=shell_hook_fail + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "WARNING: Could not download shell hook" +} + +# --------------------------------------------------------------------------- +# Git hook setup +# --------------------------------------------------------------------------- + +@test "git: not in a git repo skips hook installation silently" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + # MOCK_GIT_REPO_ROOT unset → mock git returns exit 128 (not a repo) + + run bash "$REPO_ROOT/install.sh" + + assert_success + refute_output --partial "Git post-checkout hook installed" +} + +@test "git: in a git repo installs post-checkout hook" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + local git_root="$BATS_TEST_TMPDIR/fake_repo" + mkdir -p "$git_root/.git/hooks" + export MOCK_GIT_REPO_ROOT="$git_root" + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "Git post-checkout hook installed" + assert [ -f "$git_root/.git/hooks/post-checkout" ] +} + +@test "git: hook with Luca identifier already exists is skipped" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + local git_root="$BATS_TEST_TMPDIR/fake_repo" + mkdir -p "$git_root/.git/hooks" + echo "# LUCA POST-CHECKOUT GIT HOOK" > "$git_root/.git/hooks/post-checkout" + export MOCK_GIT_REPO_ROOT="$git_root" + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "Luca post-checkout hook already installed" +} + +@test "git: foreign hook without Luca identifier warns and does not overwrite" { + echo "v2.0.0" > "$BATS_TEST_TMPDIR/.luca-version" + local git_root="$BATS_TEST_TMPDIR/fake_repo" + mkdir -p "$git_root/.git/hooks" + echo "#!/bin/sh\n# some other tool" > "$git_root/.git/hooks/post-checkout" + export MOCK_GIT_REPO_ROOT="$git_root" + + run bash "$REPO_ROOT/install.sh" + + assert_success + assert_output --partial "A post-checkout hook already exists" + # Original hook content should be unchanged + assert [ -f "$git_root/.git/hooks/post-checkout" ] +} diff --git a/tests/post_checkout.bats b/tests/post_checkout.bats new file mode 100644 index 0000000..ce553ea --- /dev/null +++ b/tests/post_checkout.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/common' + +setup() { + common_setup + # Create a fake git repo root used by most tests + export FAKE_REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$FAKE_REPO/.git/hooks" + export MOCK_GIT_REPO_ROOT="$FAKE_REPO" + # Set up the luca call log + export MOCK_LUCA_CALL_LOG="$BATS_TEST_TMPDIR/luca_calls.log" + touch "$MOCK_LUCA_CALL_LOG" +} + +# --------------------------------------------------------------------------- +# Checkout type guard +# --------------------------------------------------------------------------- + +@test "type guard: file checkout (type=0) exits 0 immediately" { + run "$REPO_ROOT/post-checkout" prev_ref new_ref 0 + + assert_success + assert_output "" +} + +@test "type guard: missing third argument defaults to 0 and exits 0" { + run "$REPO_ROOT/post-checkout" prev_ref new_ref + + assert_success + assert_output "" +} + +@test "type guard: branch checkout (type=1) proceeds" { + # With no Lucafile, script exits 0 silently after proceeding past the guard + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success +} + +# --------------------------------------------------------------------------- +# Lucafile detection +# --------------------------------------------------------------------------- + +@test "lucafile: no Lucafile in repo root exits 0 silently" { + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + refute_output --partial "synchronizing" +} + +@test "lucafile: Lucafile exists triggers synchronization message" { + cp "$FIXTURE_DIR/Lucafile" "$FAKE_REPO/Lucafile" + + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + assert_output --partial "Found Lucafile, synchronizing" +} + +@test "lucafile: git root undetermined exits 0 with warning" { + unset MOCK_GIT_REPO_ROOT + + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + assert_output --partial "Could not determine repository root" +} + +# --------------------------------------------------------------------------- +# Luca installation check +# --------------------------------------------------------------------------- + +@test "luca present: curl NOT called to install when luca already in PATH" { + cp "$FIXTURE_DIR/Lucafile" "$FAKE_REPO/Lucafile" + # luca mock is already in PATH via common_setup + + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + # curl should not have been invoked for install.sh + if [ -f "$MOCK_CALL_LOG" ]; then + refute grep -q "install.sh" "$MOCK_CALL_LOG" 2>/dev/null || true + fi +} + +@test "luca absent: curl called to install when luca not in PATH" { + cp "$FIXTURE_DIR/Lucafile" "$FAKE_REPO/Lucafile" + # Build a PATH that has git/curl mocks but NOT luca + local no_luca_bin="$BATS_TEST_TMPDIR/no_luca_bin" + mkdir -p "$no_luca_bin" + for mock in curl git sudo uname unzip; do + ln -sf "$TESTS_DIR/test_helper/mocks/$mock" "$no_luca_bin/$mock" + done + + # Intentionally omit /usr/local/bin to ensure real luca is not found + run env PATH="$no_luca_bin:/usr/bin:/bin" \ + "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_output --partial "installing" +} + +# --------------------------------------------------------------------------- +# luca install invocation +# --------------------------------------------------------------------------- + +@test "luca install: called with correct args on happy path" { + cp "$FIXTURE_DIR/Lucafile" "$FAKE_REPO/Lucafile" + + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + assert_output --partial "Tools synchronized successfully" + run grep "luca install --quiet --spec Lucafile" "$MOCK_LUCA_CALL_LOG" + assert_success +} + +@test "luca install: non-zero exit warns but post-checkout exits 0" { + cp "$FIXTURE_DIR/Lucafile" "$FAKE_REPO/Lucafile" + export MOCK_LUCA_INSTALL_EXIT_CODE=1 + + run "$REPO_ROOT/post-checkout" prev_ref new_ref 1 + + assert_success + assert_output --partial "Some tools may have failed" +} diff --git a/tests/shell_hook.bats b/tests/shell_hook.bats new file mode 100644 index 0000000..8bf7a53 --- /dev/null +++ b/tests/shell_hook.bats @@ -0,0 +1,224 @@ +#!/usr/bin/env bats + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/common' + +setup() { + common_setup +} + +# --------------------------------------------------------------------------- +# update_path() — adding entries +# --------------------------------------------------------------------------- + +@test "update_path: adds .luca/tools to PATH when directory exists" { + local project="$BATS_TEST_TMPDIR/project" + mkdir -p "$project/.luca/tools" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + cd '$project' + source '$REPO_ROOT/shell_hook.sh' + update_path + echo \"\$PATH\" + " + + assert_output --partial "$project/.luca/tools" +} + +@test "update_path: idempotent — does not duplicate PATH entry" { + local project="$BATS_TEST_TMPDIR/project" + mkdir -p "$project/.luca/tools" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + cd '$project' + source '$REPO_ROOT/shell_hook.sh' >/dev/null 2>&1 + update_path + update_path + # Count occurrences of the tools dir in PATH + echo \"\$PATH\" | tr ':' '\n' | grep -c '\.luca/tools' || echo 0 + " + + assert_output "1" +} + +@test "update_path: PATH unchanged when no .luca/tools in current dir" { + local empty_dir="$BATS_TEST_TMPDIR/empty" + mkdir -p "$empty_dir" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + cd '$empty_dir' + source '$REPO_ROOT/shell_hook.sh' + original_path=\$PATH + update_path + if [ \"\$PATH\" = \"\$original_path\" ]; then echo unchanged; else echo changed; fi + " + + assert_output --partial "unchanged" +} + +# --------------------------------------------------------------------------- +# update_path() — cleanup of stale entries +# --------------------------------------------------------------------------- + +@test "update_path: removes stale .luca/tools entry when navigating away" { + local project_a="$BATS_TEST_TMPDIR/project_a" + local empty_dir="$BATS_TEST_TMPDIR/other" + mkdir -p "$project_a/.luca/tools" "$empty_dir" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + cd '$project_a' + source '$REPO_ROOT/shell_hook.sh' + update_path + cd '$empty_dir' + update_path + echo \"\$PATH\" + " + + refute_output --partial "$project_a/.luca/tools" +} + +@test "update_path: keeps .luca/tools entry when in subdirectory of project" { + local project="$BATS_TEST_TMPDIR/myproject" + local subdir="$project/src/lib" + mkdir -p "$project/.luca/tools" "$subdir" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + cd '$project' + source '$REPO_ROOT/shell_hook.sh' + update_path + cd '$subdir' + update_path + echo \"\$PATH\" + " + + assert_output --partial "$project/.luca/tools" +} + +@test "update_path: non-luca PATH entries are never removed" { + local empty_dir="$BATS_TEST_TMPDIR/empty" + mkdir -p "$empty_dir" + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + export PATH=\"/usr/local/bin:/usr/bin:\$PATH\" + cd '$empty_dir' + source '$REPO_ROOT/shell_hook.sh' + update_path + echo \"\$PATH\" + " + + assert_output --partial "/usr/local/bin" + assert_output --partial "/usr/bin" +} + +# --------------------------------------------------------------------------- +# install_shell_hook() — bash +# --------------------------------------------------------------------------- + +@test "install_shell_hook: appends hook line to .bashrc" { + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' + " + + assert [ -f "$TEST_HOME/.bashrc" ] + run grep -c "shell_hook.sh" "$TEST_HOME/.bashrc" + assert_output "1" +} + +@test "install_shell_hook: creates .bashrc if it does not exist" { + # .bashrc does not exist in TEST_HOME + assert [ ! -f "$TEST_HOME/.bashrc" ] + + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' + " + + assert [ -f "$TEST_HOME/.bashrc" ] +} + +@test "install_shell_hook: idempotent — hook line appears exactly once after two sources" { + bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' + " + bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' + " + + run grep -c "shell_hook.sh" "$TEST_HOME/.bashrc" + assert_output "1" +} + +# --------------------------------------------------------------------------- +# install_shell_hook() — zsh +# --------------------------------------------------------------------------- + +@test "install_shell_hook: appends hook line to .zshrc for zsh" { + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/zsh + source '$REPO_ROOT/shell_hook.sh' + " + + assert [ -f "$TEST_HOME/.zshrc" ] + run grep -c "shell_hook.sh" "$TEST_HOME/.zshrc" + assert_output "1" +} + +@test "install_shell_hook: returns 1 for unsupported shell" { + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/fish + source '$REPO_ROOT/shell_hook.sh' + install_shell_hook + echo exit:\$? + " + + assert_output --partial "exit:1" +} + +# --------------------------------------------------------------------------- +# Shell hook registration +# --------------------------------------------------------------------------- + +@test "bash registration: update_path added to PROMPT_COMMAND on source" { + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' + echo \"\$PROMPT_COMMAND\" + " + + assert_output --partial "update_path" +} + +@test "bash registration: update_path not added twice on double source" { + run bash -c " + export HOME='$TEST_HOME' + export SHELL=/bin/bash + source '$REPO_ROOT/shell_hook.sh' >/dev/null 2>&1 + source '$REPO_ROOT/shell_hook.sh' >/dev/null 2>&1 + echo \"\$PROMPT_COMMAND\" | tr ';' '\n' | grep -c 'update_path' + " + + assert_output "1" +} diff --git a/tests/test_helper/common.bash b/tests/test_helper/common.bash new file mode 100644 index 0000000..c4f925f --- /dev/null +++ b/tests/test_helper/common.bash @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Resolve paths relative to this file +_COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TESTS_DIR="$(cd "$_COMMON_DIR/.." && pwd)" +REPO_ROOT="$(cd "$TESTS_DIR/.." && pwd)" +FIXTURE_DIR="$TESTS_DIR/fixtures" + +common_setup() { + export TESTS_DIR REPO_ROOT FIXTURE_DIR + + export TEST_HOME="$BATS_TEST_TMPDIR/home" + export TEST_INSTALL_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$TEST_HOME/.luca" "$TEST_INSTALL_DIR" + + # Override HOME so scripts write to temp dir instead of real home + export HOME="$TEST_HOME" + + # Override INSTALL_DIR so scripts install to temp dir (requires `:- default` patch) + export INSTALL_DIR="$TEST_INSTALL_DIR" + + # Override SHELL so scripts don't exit 1 on unsupported shell detection + export SHELL=/bin/bash + + # Prepend mocks to PATH so they shadow real commands + export PATH="$TESTS_DIR/test_helper/mocks:$TEST_INSTALL_DIR:$PATH" + + # Log file for mock invocations + export MOCK_CALL_LOG="$BATS_TEST_TMPDIR/mock_calls.log" + touch "$MOCK_CALL_LOG" + + # Sentinel file for sudo mock + export MOCK_SUDO_SENTINEL="$BATS_TEST_TMPDIR/sudo_calls.log" + + # Clear all mock control variables + unset GITHUB_TOKEN + unset MOCK_CURL_BEHAVIOR + unset MOCK_GIT_REPO_ROOT + unset MOCK_LUCA_VERSION + unset MOCK_LUCA_INSTALL_EXIT_CODE + unset MOCK_UNAME_OUTPUT + unset MOCK_UNZIP_BEHAVIOR + + # Run scripts from temp dir so VERSION_FILE (${PWD}/.luca-version) resolves here + cd "$BATS_TEST_TMPDIR" +} diff --git a/tests/test_helper/mocks/curl b/tests/test_helper/mocks/curl new file mode 100755 index 0000000..f34341f --- /dev/null +++ b/tests/test_helper/mocks/curl @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Mock curl - behavior controlled by MOCK_CURL_BEHAVIOR and URL pattern + +# Parse arguments: find --output path and URL +output_path="" +url="" +prev_arg="" +for arg in "$@"; do + if [ "$prev_arg" = "--output" ] || [ "$prev_arg" = "-o" ]; then + output_path="$arg" + fi + case "$arg" in + http://*|https://*) + url="$arg" + ;; + esac + prev_arg="$arg" +done + +# Log this invocation +echo "curl $url" >> "${MOCK_CALL_LOG:-/dev/null}" + +behavior="${MOCK_CURL_BEHAVIOR:-auto}" + +# Handle explicit forced failures first +case "$behavior" in + api_fail) + exit 1 + ;; + api_empty) + if [[ "$url" == *"releases/latest"* ]]; then + echo "{}" + exit 0 + fi + ;; + download_fail) + if [[ "$url" == *"releases/download"* ]]; then + exit 1 + fi + ;; + shell_hook_fail) + if [[ "$url" == *"shell_hook.sh"* ]]; then + exit 1 + fi + ;; + post_checkout_fail) + if [[ "$url" == *"post-checkout"* ]]; then + exit 1 + fi + ;; +esac + +# Auto behavior based on URL pattern +if [[ "$url" == *"releases/latest"* ]]; then + # GitHub API: return fixture JSON + if [ -n "${FIXTURE_DIR:-}" ] && [ -f "$FIXTURE_DIR/api_response_latest.json" ]; then + cat "$FIXTURE_DIR/api_response_latest.json" + else + echo '{"tag_name": "v2.0.0"}' + fi + exit 0 + +elif [[ "$url" == *"releases/download"* ]]; then + # Binary download: create a placeholder zip + if [ -n "$output_path" ]; then + touch "$output_path" + fi + exit 0 + +elif [[ "$url" == *"shell_hook.sh"* ]]; then + # shell_hook.sh download: write a minimal valid bash script + if [ -n "$output_path" ]; then + cat > "$output_path" << 'MOCK_SHELL_HOOK' +#!/usr/bin/env bash +TOOL_FOLDER=".luca" + +install_shell_hook() { + local shell_rc_file + local hook_line="[[ -s \"\$HOME/$TOOL_FOLDER/shell_hook.sh\" ]] && source \"\$HOME/$TOOL_FOLDER/shell_hook.sh\"" + case "$SHELL" in + */bash) shell_rc_file="$HOME/.bashrc" ;; + */zsh) shell_rc_file="$HOME/.zshrc" ;; + *) return 1 ;; + esac + touch "$shell_rc_file" + if ! grep -Fxq "$hook_line" "$shell_rc_file" 2>/dev/null; then + { + echo "" + echo "# Initialize Luca shell hook (added by Luca installer)" + echo "$hook_line" + } >> "$shell_rc_file" + fi +} + +update_path() { return 0; } + +if [[ -n "$BASH_VERSION" && "${BASH_SOURCE[0]}" != "$0" ]] || \ + [[ -n "$ZSH_VERSION" && "${ZSH_EVAL_CONTEXT}" == *:file:* ]]; then + install_shell_hook + update_path +fi +MOCK_SHELL_HOOK + chmod +x "$output_path" + fi + exit 0 + +elif [[ "$url" == *"post-checkout"* ]] || [[ "$url" == *"install.sh"* ]]; then + # post-checkout or install script download: write a minimal no-op + if [ -n "$output_path" ]; then + printf '#!/bin/sh\n# mock script\nexit 0\n' > "$output_path" + chmod +x "$output_path" + fi + exit 0 +fi + +exit 0 diff --git a/tests/test_helper/mocks/git b/tests/test_helper/mocks/git new file mode 100755 index 0000000..a778a2b --- /dev/null +++ b/tests/test_helper/mocks/git @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Mock git - controlled by MOCK_GIT_REPO_ROOT + +echo "git $*" >> "${MOCK_CALL_LOG:-/dev/null}" + +# Handle rev-parse --show-toplevel +if [ "$1" = "rev-parse" ] && [ "$2" = "--show-toplevel" ]; then + if [ -n "${MOCK_GIT_REPO_ROOT:-}" ]; then + echo "$MOCK_GIT_REPO_ROOT" + exit 0 + else + echo "fatal: not a git repository (or any of the parent directories): .git" >&2 + exit 128 + fi +fi + +exit 0 diff --git a/tests/test_helper/mocks/luca b/tests/test_helper/mocks/luca new file mode 100755 index 0000000..6858179 --- /dev/null +++ b/tests/test_helper/mocks/luca @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Mock luca - controlled by MOCK_LUCA_VERSION and MOCK_LUCA_INSTALL_EXIT_CODE + +echo "luca $*" >> "${MOCK_LUCA_CALL_LOG:-/dev/null}" + +case "${1:-}" in + --version) + echo "${MOCK_LUCA_VERSION:-v1.0.0}" + exit 0 + ;; + install) + exit "${MOCK_LUCA_INSTALL_EXIT_CODE:-0}" + ;; + *) + exit 0 + ;; +esac diff --git a/tests/test_helper/mocks/sudo b/tests/test_helper/mocks/sudo new file mode 100755 index 0000000..1dfb903 --- /dev/null +++ b/tests/test_helper/mocks/sudo @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Mock sudo - logs call and executes the command as current user + +echo "sudo $*" >> "${MOCK_SUDO_SENTINEL:-/dev/null}" +"$@" diff --git a/tests/test_helper/mocks/uname b/tests/test_helper/mocks/uname new file mode 100755 index 0000000..b06b906 --- /dev/null +++ b/tests/test_helper/mocks/uname @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Mock uname - returns MOCK_UNAME_OUTPUT (default: Darwin) + +echo "${MOCK_UNAME_OUTPUT:-Darwin}" diff --git a/tests/test_helper/mocks/unzip b/tests/test_helper/mocks/unzip new file mode 100755 index 0000000..b1f8746 --- /dev/null +++ b/tests/test_helper/mocks/unzip @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Mock unzip - creates a fake Luca binary in the current directory + +echo "unzip $*" >> "${MOCK_CALL_LOG:-/dev/null}" + +case "${MOCK_UNZIP_BEHAVIOR:-success}" in + success) + # Create a fake Luca executable (install.sh looks for ./Luca) + printf '#!/usr/bin/env bash\necho "%s"\n' "${MOCK_LUCA_VERSION:-v2.0.0}" > "./Luca" + chmod +x "./Luca" + exit 0 + ;; + fail) + echo "unzip: cannot extract" >&2 + exit 1 + ;; +esac diff --git a/tests/uninstall.bats b/tests/uninstall.bats new file mode 100644 index 0000000..e9fe067 --- /dev/null +++ b/tests/uninstall.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats + +load 'test_helper/bats-support/load' +load 'test_helper/bats-assert/load' +load 'test_helper/common' + +setup() { + common_setup +} + +# --------------------------------------------------------------------------- +# Executable removal +# --------------------------------------------------------------------------- + +@test "executable: removes Luca binary when it exists" { + # uninstall.sh uses TOOL_NAME=Luca (uppercase), so binary is $INSTALL_DIR/Luca + touch "$TEST_INSTALL_DIR/Luca" + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert [ ! -f "$TEST_INSTALL_DIR/Luca" ] + assert_output --partial "executable has been removed" +} + +@test "executable: graceful when binary does not exist" { + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "executable not found" +} + +# --------------------------------------------------------------------------- +# Shell hook removal (bash) +# --------------------------------------------------------------------------- + +@test "bashrc: removes hook line and comment when present" { + cp "$FIXTURE_DIR/bashrc_with_hook" "$TEST_HOME/.bashrc" + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "Shell hook removed" + run grep -c "shell_hook.sh" "$TEST_HOME/.bashrc" + assert_output "0" +} + +@test "bashrc: no-op when hook is not in .bashrc" { + echo 'export FOO=bar' > "$TEST_HOME/.bashrc" + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "No shell hook found" +} + +@test "bashrc: no-op when .bashrc does not exist" { + # .bashrc does not exist; TEST_HOME/.bashrc was not created + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "Shell configuration file not found" +} + +# --------------------------------------------------------------------------- +# Shell hook removal (zsh) +# --------------------------------------------------------------------------- + +@test "zshrc: removes hook from .zshrc" { + cp "$FIXTURE_DIR/bashrc_with_hook" "$TEST_HOME/.zshrc" + export SHELL=/bin/zsh + + run env SHELL=/bin/zsh bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "Shell hook removed" + run grep -c "shell_hook.sh" "$TEST_HOME/.zshrc" + assert_output "0" +} + +@test "unsupported shell: warns but exits 0" { + run env SHELL=/bin/fish bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "Unsupported shell" +} + +# --------------------------------------------------------------------------- +# Tool directory removal +# --------------------------------------------------------------------------- + +@test "tool dir: removes ~/.luca directory entirely" { + mkdir -p "$TEST_HOME/.luca/tools" + touch "$TEST_HOME/.luca/some_config" + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert [ ! -d "$TEST_HOME/.luca" ] + assert_output --partial "Tool directory has been removed" +} + +@test "tool dir: graceful when ~/.luca does not exist" { + rm -rf "$TEST_HOME/.luca" + + run bash "$REPO_ROOT/uninstall.sh" + + assert_success + assert_output --partial "Tool directory not found" +} diff --git a/uninstall.sh b/uninstall.sh index 7876819..b283c45 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -8,7 +8,7 @@ # ============================================================================= TOOL_NAME="Luca" -INSTALL_DIR="/usr/local/bin" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" TOOL_FOLDER=".luca" TOOL_DIR="$HOME/$TOOL_FOLDER" SHELL_HOOK_SCRIPT_PATH="$TOOL_DIR/shell_hook.sh"