From f9a93f605285d44c304d3c9d9001eab9a24e88cf Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 12 Oct 2025 14:17:40 +0800 Subject: [PATCH] Complete automated testing --- .github/workflows/tests.yml | 22 +++++ tests/analyze.bats | 71 ++++++++++++++++ tests/clean.bats | 60 +++++++++++++ tests/cli.bats | 80 ++++++++++++++++++ tests/common.bats | 140 +++++++++++++++++++++++++++++++ tests/helpers/uninstall_stubs.sh | 22 +++++ tests/run.sh | 29 +++++++ tests/uninstall.bats | 104 +++++++++++++++++++++++ tests/update_remove.bats | 111 ++++++++++++++++++++++++ tests/whitelist.bats | 99 ++++++++++++++++++++++ 10 files changed, 738 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/analyze.bats create mode 100644 tests/clean.bats create mode 100644 tests/cli.bats create mode 100644 tests/common.bats create mode 100644 tests/helpers/uninstall_stubs.sh create mode 100755 tests/run.sh create mode 100644 tests/uninstall.bats create mode 100644 tests/update_remove.bats create mode 100644 tests/whitelist.bats diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0c2bde3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Mole Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install bats-core + run: | + brew update + brew install bats-core + + - name: Run test suite + run: tests/run.sh diff --git a/tests/analyze.bats b/tests/analyze.bats new file mode 100644 index 0000000..2a780ef --- /dev/null +++ b/tests/analyze.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-analyze-home.XXXXXX")" + export HOME +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" +} + +@test "scan_directories lists largest folders first" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/analyze.sh" + +root="$HOME/analyze-root" +mkdir -p "$root/Small" "$root/Large" +printf 'tiny' > "$root/Small/file.txt" +dd if=/dev/zero of="$root/Large/big.dat" bs=1024 count=200 >/dev/null 2>&1 + +output_file="$HOME/directories.txt" +scan_directories "$root" "$output_file" 1 + +head -n1 "$output_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Large"* ]] +} + +@test "aggregate_by_directory sums child sizes per parent" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/bin/analyze.sh" + +root="$HOME/group" +mkdir -p "$root/a" "$root/b" + +input_file="$HOME/files.txt" +cat > "$input_file" < "$HOME/Library/Caches/TestApp/cache.tmp" + + run env HOME="$HOME" "$PROJECT_ROOT/mole" clean --dry-run + [ "$status" -eq 0 ] + [[ "$output" == *"User app cache"* ]] + [[ "$output" == *"Potential space"* ]] + [ -f "$HOME/Library/Caches/TestApp/cache.tmp" ] +} + +@test "mo clean honors whitelist entries" { + mkdir -p "$HOME/Library/Caches/WhitelistedApp" + echo "keep me" > "$HOME/Library/Caches/WhitelistedApp/data.tmp" + + cat > "$HOME/.config/mole/whitelist" </dev/null 2>'$stderr_file'" + + [[ -s "$stderr_file" ]] + grep -q "$message" "$stderr_file" + + local log_file="$HOME/.config/mole/mole.log" + [[ -f "$log_file" ]] + grep -q "ERROR: $message" "$log_file" +} + +@test "bytes_to_human converts byte counts into readable units" { + output="$(HOME="$HOME" bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/common.sh" +bytes_to_human 512 +bytes_to_human 2048 +bytes_to_human $((5 * 1024 * 1024)) +bytes_to_human $((3 * 1024 * 1024 * 1024)) +EOF +)" + + bytes_lines=() + while IFS= read -r line; do + bytes_lines+=("$line") + done <<< "$output" + + [ "${bytes_lines[0]}" = "512B" ] + [ "${bytes_lines[1]}" = "2KB" ] + [ "${bytes_lines[2]}" = "5.0MB" ] + [ "${bytes_lines[3]}" = "3.00GB" ] +} + +@test "create_temp_file and create_temp_dir are tracked and cleaned" { + HOME="$HOME" bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/common.sh" +create_temp_file > "$HOME/temp_file_path.txt" +create_temp_dir > "$HOME/temp_dir_path.txt" +cleanup_temp_files +EOF + + file_path="$(cat "$HOME/temp_file_path.txt")" + dir_path="$(cat "$HOME/temp_dir_path.txt")" + [ ! -e "$file_path" ] + [ ! -e "$dir_path" ] + rm -f "$HOME/temp_file_path.txt" "$HOME/temp_dir_path.txt" +} + +@test "parallel_execute runs worker across all items" { + output_file="$HOME/parallel_output.txt" + HOME="$HOME" bash --noprofile --norc <<'EOF' +source "$PROJECT_ROOT/lib/common.sh" +worker() { + echo "$1" >> "$HOME/parallel_output.txt" +} +parallel_execute 2 worker "first" "second" "third" +EOF + + sort "$output_file" > "$output_file.sorted" + results=() + while IFS= read -r line; do + results+=("$line") + done < "$output_file.sorted" + + [ "${#results[@]}" -eq 3 ] + joined=" ${results[*]} " + [[ "$joined" == *" first "* ]] + [[ "$joined" == *" second "* ]] + [[ "$joined" == *" third "* ]] + rm -f "$output_file" "$output_file.sorted" +} diff --git a/tests/helpers/uninstall_stubs.sh b/tests/helpers/uninstall_stubs.sh new file mode 100644 index 0000000..7689c6e --- /dev/null +++ b/tests/helpers/uninstall_stubs.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2329 +# Helper stub definitions for uninstall tests + +setup_uninstall_stubs() { + request_sudo_access() { return 0; } + start_inline_spinner() { :; } + stop_inline_spinner() { :; } + enter_alt_screen() { :; } + leave_alt_screen() { :; } + hide_cursor() { :; } + show_cursor() { :; } + remove_apps_from_dock() { :; } + + pgrep() { return 1; } + pkill() { return 0; } + sudo() { return 0; } + + export -f request_sudo_access start_inline_spinner stop_inline_spinner \ + enter_alt_screen leave_alt_screen hide_cursor show_cursor \ + remove_apps_from_dock pgrep pkill sudo || true +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..580a778 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if ! command -v bats >/dev/null 2>&1; then + cat <<'EOF' >&2 +bats is required to run Mole's test suite. +Install via Homebrew with 'brew install bats-core' or via npm with 'npm install -g bats'. +EOF + exit 1 +fi + +cd "$PROJECT_ROOT" + +if [[ -z "${TERM:-}" ]]; then + export TERM="xterm-256color" +fi + +if [[ $# -eq 0 ]]; then + set -- tests +fi + +if [[ -t 1 ]]; then + bats -p "$@" +else + TERM="${TERM:-xterm-256color}" bats --tap "$@" +fi diff --git a/tests/uninstall.bats b/tests/uninstall.bats new file mode 100644 index 0000000..47d7eee --- /dev/null +++ b/tests/uninstall.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" + export HOME +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" +} + +create_app_artifacts() { + mkdir -p "$HOME/Applications/TestApp.app" + mkdir -p "$HOME/Library/Application Support/TestApp" + mkdir -p "$HOME/Library/Caches/TestApp" + mkdir -p "$HOME/Library/Containers/com.example.TestApp" + mkdir -p "$HOME/Library/Preferences" + touch "$HOME/Library/Preferences/com.example.TestApp.plist" + mkdir -p "$HOME/Library/Preferences/ByHost" + touch "$HOME/Library/Preferences/ByHost/com.example.TestApp.ABC123.plist" + mkdir -p "$HOME/Library/Saved Application State/com.example.TestApp.savedState" +} + +@test "find_app_files discovers user-level leftovers" { + create_app_artifacts + + result="$(HOME="$HOME" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/common.sh" +find_app_files "com.example.TestApp" "TestApp" +EOF +)" + + [[ "$result" == *"Application Support/TestApp"* ]] + [[ "$result" == *"Caches/TestApp"* ]] + [[ "$result" == *"Preferences/com.example.TestApp.plist"* ]] + [[ "$result" == *"Saved Application State/com.example.TestApp.savedState"* ]] + [[ "$result" == *"Containers/com.example.TestApp"* ]] +} + +@test "calculate_total_size returns aggregate kilobytes" { + mkdir -p "$HOME/sized" + dd if=/dev/zero of="$HOME/sized/file1" bs=1024 count=1 >/dev/null 2>&1 + dd if=/dev/zero of="$HOME/sized/file2" bs=1024 count=2 >/dev/null 2>&1 + + result="$(HOME="$HOME" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/common.sh" +files="$(printf '%s\n%s\n' "$HOME/sized/file1" "$HOME/sized/file2")" +calculate_total_size "$files" +EOF +)" + + # Result should be >=3 KB (some filesystems allocate slightly more) + [ "$result" -ge 3 ] +} + +@test "batch_uninstall_applications removes selected app data" { + create_app_artifacts + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/common.sh" +source "$PROJECT_ROOT/lib/batch_uninstall.sh" +source "$PROJECT_ROOT/tests/helpers/uninstall_stubs.sh" +setup_uninstall_stubs + +app_bundle="$HOME/Applications/TestApp.app" +mkdir -p "$app_bundle" + +related="$(find_app_files "com.example.TestApp" "TestApp")" +encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') + +selected_apps=() +selected_apps+=("0|$app_bundle|TestApp|com.example.TestApp|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications >/dev/null + +[[ ! -d "$app_bundle" ]] || exit 1 +[[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 +[[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 +[[ ! -f "$HOME/Library/Preferences/com.example.TestApp.plist" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] +} diff --git a/tests/update_remove.bats b/tests/update_remove.bats new file mode 100644 index 0000000..1f1e7e5 --- /dev/null +++ b/tests/update_remove.bats @@ -0,0 +1,111 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-update-home.XXXXXX")" + export HOME +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + export TERM="dumb" + rm -rf "${HOME:?}"/* + mkdir -p "$HOME" +} + +@test "update_via_homebrew reports already on latest version" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +MOLE_TEST_BREW_UPDATE_OUTPUT="Updated 0 formulae" +MOLE_TEST_BREW_UPGRADE_OUTPUT="Warning: mole 1.7.9 already installed" +MOLE_TEST_BREW_LIST_OUTPUT="mole 1.7.9" +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +brew() { + case "$1" in + update) echo "$MOLE_TEST_BREW_UPDATE_OUTPUT";; + upgrade) echo "$MOLE_TEST_BREW_UPGRADE_OUTPUT";; + list) if [[ "$2" == "--versions" ]]; then echo "$MOLE_TEST_BREW_LIST_OUTPUT"; fi ;; + esac +} +export -f brew start_inline_spinner stop_inline_spinner +source "$PROJECT_ROOT/lib/common.sh" +update_via_homebrew "1.7.9" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Already on latest version"* ]] +} + +@test "update_mole skips download when already latest" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="$HOME/fake-bin:/usr/bin:/bin" TERM="dumb" bash --noprofile --norc <<'EOF' +set -euo pipefail +mkdir -p "$HOME/fake-bin" +cat > "$HOME/fake-bin/curl" <<'SCRIPT' +#!/usr/bin/env bash +out="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +if [[ -n "$out" ]]; then + cat <<'INSTALLER' > "$out" +#!/usr/bin/env bash +echo "Installer executed" +INSTALLER +else + echo 'VERSION="1.7.9"' +fi +SCRIPT +chmod +x "$HOME/fake-bin/curl" +cat > "$HOME/fake-bin/brew" <<'SCRIPT' +#!/usr/bin/env bash +exit 1 +SCRIPT +chmod +x "$HOME/fake-bin/brew" + +"$PROJECT_ROOT/mole" update +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Already on latest version"* ]] +} + +@test "remove_mole deletes manual binaries and caches" { + mkdir -p "$HOME/.local/bin" + touch "$HOME/.local/bin/mole" + touch "$HOME/.local/bin/mo" + mkdir -p "$HOME/.config/mole" "$HOME/.cache/mole" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PATH="/usr/bin:/bin" bash --noprofile --norc <<'EOF' +set -euo pipefail +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +export -f start_inline_spinner stop_inline_spinner +printf '\n' | "$PROJECT_ROOT/mole" remove +EOF + + [ "$status" -eq 0 ] + [ ! -f "$HOME/.local/bin/mole" ] + [ ! -f "$HOME/.local/bin/mo" ] + [ ! -d "$HOME/.config/mole" ] + [ ! -d "$HOME/.cache/mole" ] +} diff --git a/tests/whitelist.bats b/tests/whitelist.bats new file mode 100644 index 0000000..97f083e --- /dev/null +++ b/tests/whitelist.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats + +setup_file() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + + ORIGINAL_HOME="${HOME:-}" + export ORIGINAL_HOME + + HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-whitelist-home.XXXXXX")" + export HOME + + mkdir -p "$HOME" +} + +teardown_file() { + rm -rf "$HOME" + if [[ -n "${ORIGINAL_HOME:-}" ]]; then + export HOME="$ORIGINAL_HOME" + fi +} + +setup() { + rm -rf "$HOME/.config" + mkdir -p "$HOME" + WHITELIST_PATH="$HOME/.config/mole/whitelist" +} + +@test "patterns_equivalent treats paths with tilde expansion as equal" { + local status + if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/test\""; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] +} + +@test "patterns_equivalent distinguishes different paths" { + local status + if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; patterns_equivalent '~/.cache/test' \"\$HOME/.cache/other\""; then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] +} + +@test "save_whitelist_patterns keeps unique entries and preserves header" { + HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; save_whitelist_patterns \"\$HOME/.cache/foo\" \"\$HOME/.cache/foo\" \"\$HOME/.cache/bar\"" + + [[ -f "$WHITELIST_PATH" ]] + + lines=() + while IFS= read -r line; do + lines+=("$line") + done < "$WHITELIST_PATH" + # Header is at least two lines (comments), plus two unique patterns + [ "${#lines[@]}" -ge 4 ] + # Ensure duplicate was not written twice + occurrences=$(grep -c "$HOME/.cache/foo" "$WHITELIST_PATH") + [ "$occurrences" -eq 1 ] +} + +@test "load_whitelist falls back to defaults when config missing" { + rm -f "$WHITELIST_PATH" + HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; rm -f \"\$HOME/.config/mole/whitelist\"; load_whitelist; printf '%s\n' \"\${CURRENT_WHITELIST_PATTERNS[@]}\"" > "$HOME/current_whitelist.txt" + HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; printf '%s\n' \"\${DEFAULT_WHITELIST_PATTERNS[@]}\"" > "$HOME/default_whitelist.txt" + + current=() + while IFS= read -r line; do + current+=("$line") + done < "$HOME/current_whitelist.txt" + + defaults=() + while IFS= read -r line; do + defaults+=("$line") + done < "$HOME/default_whitelist.txt" + + [ "${#current[@]}" -eq "${#defaults[@]}" ] + [ "${current[0]}" = "${defaults[0]/\$HOME/$HOME}" ] +} + +@test "is_whitelisted matches saved patterns exactly" { + local status + if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/unique-pattern\""; then + status=0 + else + status=$? + fi + [ "$status" -eq 0 ] + + if HOME="$HOME" bash --noprofile --norc -c "source '$PROJECT_ROOT/lib/whitelist_manager.sh'; save_whitelist_patterns \"\$HOME/.cache/unique-pattern\"; load_whitelist; is_whitelisted \"\$HOME/.cache/other-pattern\""; then + status=0 + else + status=$? + fi + [ "$status" -ne 0 ] +}