1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 20:15:07 +00:00

Add dry-run support across destructive commands (#516)

* chore: update contributors [skip ci]

* Add dry-run support across destructive commands

Implement dry-run for uninstall, purge, installer, touchid, completion, and remove flows.\nGuard side effects in uninstall path (launchctl, defaults writes, kill/brew actions), update help/README, and add coverage in CLI/Bats tests.\n\nValidation: ./scripts/check.sh and ./scripts/test.sh (452 tests, 0 failures, 8 skipped).

* test(purge): keep dev-compatible purge coverage

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tw93 <hitw93@gmail.com>
This commit is contained in:
陳德生
2026-03-01 20:03:22 +08:00
committed by GitHub
parent adcd98096a
commit 05446e0847
18 changed files with 1021 additions and 684 deletions

View File

@@ -1,39 +1,39 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")"
export HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
create_fake_utils() {
local dir="$1"
mkdir -p "$dir"
local dir="$1"
mkdir -p "$dir"
cat > "$dir/sudo" <<'SCRIPT'
cat >"$dir/sudo" <<'SCRIPT'
#!/usr/bin/env bash
if [[ "$1" == "-n" || "$1" == "-v" ]]; then
exit 0
fi
exec "$@"
SCRIPT
chmod +x "$dir/sudo"
chmod +x "$dir/sudo"
cat > "$dir/bioutil" <<'SCRIPT'
cat >"$dir/bioutil" <<'SCRIPT'
#!/usr/bin/env bash
if [[ "$1" == "-r" ]]; then
echo "Touch ID: 1"
@@ -41,138 +41,152 @@ if [[ "$1" == "-r" ]]; then
fi
exit 0
SCRIPT
chmod +x "$dir/bioutil"
chmod +x "$dir/bioutil"
}
setup() {
rm -rf "$HOME/.config"
mkdir -p "$HOME"
rm -rf "$HOME/.config"
mkdir -p "$HOME"
}
@test "mole --help prints command overview" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ]
[[ "$output" == *"mo clean"* ]]
[[ "$output" == *"mo analyze"* ]]
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ]
[[ "$output" == *"mo clean"* ]]
[[ "$output" == *"mo analyze"* ]]
}
@test "mole --version reports script version" {
expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')"
run env HOME="$HOME" "$PROJECT_ROOT/mole" --version
[ "$status" -eq 0 ]
[[ "$output" == *"$expected_version"* ]]
expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')"
run env HOME="$HOME" "$PROJECT_ROOT/mole" --version
[ "$status" -eq 0 ]
[[ "$output" == *"$expected_version"* ]]
}
@test "mole unknown command returns error" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command
[ "$status" -ne 0 ]
[[ "$output" == *"Unknown command: unknown-command"* ]]
run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command
[ "$status" -ne 0 ]
[[ "$output" == *"Unknown command: unknown-command"* ]]
}
@test "touchid status reports current configuration" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status
[ "$status" -eq 0 ]
[[ "$output" == *"Touch ID"* ]]
run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status
[ "$status" -eq 0 ]
[[ "$output" == *"Touch ID"* ]]
}
@test "mo optimize command is recognized" {
run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'"
[ "$status" -eq 0 ]
run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'"
[ "$status" -eq 0 ]
}
@test "mo analyze binary is valid" {
if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then
[ -x "$PROJECT_ROOT/bin/analyze-go" ]
run file "$PROJECT_ROOT/bin/analyze-go"
[[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]]
else
skip "analyze-go binary not built"
fi
if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then
[ -x "$PROJECT_ROOT/bin/analyze-go" ]
run file "$PROJECT_ROOT/bin/analyze-go"
[[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]]
else
skip "analyze-go binary not built"
fi
}
@test "mo clean --debug creates debug log file" {
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
MOLE_OUTPUT="$output"
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
MOLE_OUTPUT="$output"
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
[ -f "$DEBUG_LOG" ]
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
[ -f "$DEBUG_LOG" ]
run grep "Mole Debug Session" "$DEBUG_LOG"
[ "$status" -eq 0 ]
run grep "Mole Debug Session" "$DEBUG_LOG"
[ "$status" -eq 0 ]
[[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]]
[[ "$MOLE_OUTPUT" =~ "Debug session log saved to" ]]
}
@test "mo clean without debug does not show debug log path" {
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
[[ "$output" != *"Debug session log saved to"* ]]
[[ "$output" != *"Debug session log saved to"* ]]
}
@test "mo clean --debug logs system info" {
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
mkdir -p "$HOME/.config/mole"
run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ]
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
run grep "User:" "$DEBUG_LOG"
[ "$status" -eq 0 ]
run grep "User:" "$DEBUG_LOG"
[ "$status" -eq 0 ]
run grep "Architecture:" "$DEBUG_LOG"
[ "$status" -eq 0 ]
run grep "Architecture:" "$DEBUG_LOG"
[ "$status" -eq 0 ]
}
@test "touchid status reflects pam file contents" {
pam_file="$HOME/pam_test"
cat > "$pam_file" <<'EOF'
pam_file="$HOME/pam_test"
cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so
EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ]
[[ "$output" == *"not configured"* ]]
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ]
[[ "$output" == *"not configured"* ]]
cat > "$pam_file" <<'EOF'
cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so
EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ]
[[ "$output" == *"enabled"* ]]
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ]
[[ "$output" == *"enabled"* ]]
}
@test "enable_touchid inserts pam_tid line in pam file" {
pam_file="$HOME/pam_enable"
cat > "$pam_file" <<'EOF'
pam_file="$HOME/pam_enable"
cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so
EOF
fake_bin="$HOME/fake-bin"
create_fake_utils "$fake_bin"
fake_bin="$HOME/fake-bin"
create_fake_utils "$fake_bin"
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable
[ "$status" -eq 0 ]
grep -q "pam_tid.so" "$pam_file"
[[ -f "${pam_file}.mole-backup" ]]
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable
[ "$status" -eq 0 ]
grep -q "pam_tid.so" "$pam_file"
[[ -f "${pam_file}.mole-backup" ]]
}
@test "disable_touchid removes pam_tid line" {
pam_file="$HOME/pam_disable"
cat > "$pam_file" <<'EOF'
pam_file="$HOME/pam_disable"
cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so
auth sufficient pam_opendirectory.so
EOF
fake_bin="$HOME/fake-bin-disable"
create_fake_utils "$fake_bin"
fake_bin="$HOME/fake-bin-disable"
create_fake_utils "$fake_bin"
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable
[ "$status" -eq 0 ]
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable
[ "$status" -eq 0 ]
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
}
@test "touchid enable --dry-run does not modify pam file" {
pam_file="$HOME/pam_enable_dry_run"
cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so
EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ]
}

View File

@@ -1,160 +1,165 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
ORIGINAL_PATH="${PATH:-}"
export ORIGINAL_PATH
ORIGINAL_PATH="${PATH:-}"
export ORIGINAL_PATH
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")"
export HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
mkdir -p "$HOME"
PATH="$PROJECT_ROOT:$PATH"
export PATH
PATH="$PROJECT_ROOT:$PATH"
export PATH
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
if [[ -n "${ORIGINAL_PATH:-}" ]]; then
export PATH="$ORIGINAL_PATH"
fi
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
if [[ -n "${ORIGINAL_PATH:-}" ]]; then
export PATH="$ORIGINAL_PATH"
fi
}
setup() {
rm -rf "$HOME/.config"
rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
mkdir -p "$HOME"
rm -rf "$HOME/.config"
rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
mkdir -p "$HOME"
}
@test "completion script exists and is executable" {
[ -f "$PROJECT_ROOT/bin/completion.sh" ]
[ -x "$PROJECT_ROOT/bin/completion.sh" ]
[ -f "$PROJECT_ROOT/bin/completion.sh" ]
[ -x "$PROJECT_ROOT/bin/completion.sh" ]
}
@test "completion script has valid bash syntax" {
run bash -n "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ]
run bash -n "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ]
}
@test "completion --help shows usage" {
run "$PROJECT_ROOT/bin/completion.sh" --help
[ "$status" -ne 0 ]
[[ "$output" == *"Usage: mole completion"* ]]
[[ "$output" == *"Auto-install"* ]]
run "$PROJECT_ROOT/bin/completion.sh" --help
[ "$status" -ne 0 ]
[[ "$output" == *"Usage: mole completion"* ]]
[[ "$output" == *"Auto-install"* ]]
}
@test "completion bash generates valid bash script" {
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]]
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]]
}
@test "completion bash script includes all commands" {
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"optimize"* ]]
[[ "$output" == *"clean"* ]]
[[ "$output" == *"uninstall"* ]]
[[ "$output" == *"analyze"* ]]
[[ "$output" == *"status"* ]]
[[ "$output" == *"purge"* ]]
[[ "$output" == *"touchid"* ]]
[[ "$output" == *"completion"* ]]
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"optimize"* ]]
[[ "$output" == *"clean"* ]]
[[ "$output" == *"uninstall"* ]]
[[ "$output" == *"analyze"* ]]
[[ "$output" == *"status"* ]]
[[ "$output" == *"purge"* ]]
[[ "$output" == *"touchid"* ]]
[[ "$output" == *"completion"* ]]
}
@test "completion bash script supports mo command" {
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]]
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]]
}
@test "completion bash can be loaded in bash" {
run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole"
[ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]]
run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole"
[ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]]
}
@test "completion zsh generates valid zsh script" {
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
[[ "$output" == *"#compdef mole mo"* ]]
[[ "$output" == *"_mole()"* ]]
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
[[ "$output" == *"#compdef mole mo"* ]]
[[ "$output" == *"_mole()"* ]]
}
@test "completion zsh includes command descriptions" {
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
[[ "$output" == *"optimize:Check and maintain system"* ]]
[[ "$output" == *"clean:Free up disk space"* ]]
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
[[ "$output" == *"optimize:Check and maintain system"* ]]
[[ "$output" == *"clean:Free up disk space"* ]]
}
@test "completion fish generates valid fish script" {
run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ]
[[ "$output" == *"complete -c mole"* ]]
[[ "$output" == *"complete -c mo"* ]]
run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ]
[[ "$output" == *"complete -c mole"* ]]
[[ "$output" == *"complete -c mo"* ]]
}
@test "completion fish includes both mole and mo commands" {
output="$("$PROJECT_ROOT/bin/completion.sh" fish)"
mole_count=$(echo "$output" | grep -c "complete -c mole")
mo_count=$(echo "$output" | grep -c "complete -c mo")
output="$("$PROJECT_ROOT/bin/completion.sh" fish)"
mole_count=$(echo "$output" | grep -c "complete -c mole")
mo_count=$(echo "$output" | grep -c "complete -c mo")
[ "$mole_count" -gt 0 ]
[ "$mo_count" -gt 0 ]
[ "$mole_count" -gt 0 ]
[ "$mo_count" -gt 0 ]
}
@test "completion auto-install detects zsh" {
# shellcheck disable=SC2030,SC2031
export SHELL=/bin/zsh
# shellcheck disable=SC2030,SC2031
export SHELL=/bin/zsh
# Simulate auto-install (no interaction)
run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\""
# Simulate auto-install (no interaction)
run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\""
if [[ "$output" == *"Already configured"* ]]; then
skip "Already configured from previous test"
fi
if [[ "$output" == *"Already configured"* ]]; then
skip "Already configured from previous test"
fi
[ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc"
[ -f "$HOME/.zshrc" ] || skip "Auto-install didn't create .zshrc"
run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc"
[ "$status" -eq 0 ]
run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc"
[ "$status" -eq 0 ]
}
@test "completion auto-install detects already installed" {
# shellcheck disable=SC2031
export SHELL=/bin/zsh
mkdir -p "$HOME"
# shellcheck disable=SC2016
echo 'eval "$(mole completion zsh)"' > "$HOME/.zshrc"
mkdir -p "$HOME"
# shellcheck disable=SC2016
echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc"
run "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"updated"* ]]
run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"updated"* ]]
}
@test "completion --dry-run previews changes without writing config" {
run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh" --dry-run
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ ! -f "$HOME/.zshrc" ]
}
@test "completion script handles invalid shell argument" {
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
[ "$status" -ne 0 ]
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
[ "$status" -ne 0 ]
}
@test "completion subcommand supports bash/zsh/fish" {
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ]
}

View File

@@ -1,49 +1,56 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
export HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
setup() {
export TERM="xterm-256color"
export MO_DEBUG=0
export TERM="xterm-256color"
export MO_DEBUG=0
# Create standard scan directories
mkdir -p "$HOME/Downloads"
mkdir -p "$HOME/Desktop"
mkdir -p "$HOME/Documents"
mkdir -p "$HOME/Public"
mkdir -p "$HOME/Library/Downloads"
# Create standard scan directories
mkdir -p "$HOME/Downloads"
mkdir -p "$HOME/Desktop"
mkdir -p "$HOME/Documents"
mkdir -p "$HOME/Public"
mkdir -p "$HOME/Library/Downloads"
# Clear previous test files
rm -rf "${HOME:?}/Downloads"/*
rm -rf "${HOME:?}/Desktop"/*
rm -rf "${HOME:?}/Documents"/*
# Clear previous test files
rm -rf "${HOME:?}/Downloads"/*
rm -rf "${HOME:?}/Desktop"/*
rm -rf "${HOME:?}/Documents"/*
}
# Test arguments
@test "installer.sh rejects unknown options" {
run "$PROJECT_ROOT/bin/installer.sh" --unknown-option
run "$PROJECT_ROOT/bin/installer.sh" --unknown-option
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown option"* ]]
[ "$status" -eq 1 ]
[[ "$output" == *"Unknown option"* ]]
}
@test "installer.sh accepts --dry-run option" {
run env HOME="$HOME" TERM="xterm-256color" "$PROJECT_ROOT/bin/installer.sh" --dry-run
[[ "$status" -eq 0 || "$status" -eq 2 ]]
[[ "$output" == *"DRY RUN MODE"* ]]
}
# Test scan_installers_in_path function directly
@@ -53,187 +60,187 @@ setup() {
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@test "scan_installers_in_path (fallback find): finds .dmg files" {
touch "$HOME/Downloads/Chrome.dmg"
touch "$HOME/Downloads/Chrome.dmg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"Chrome.dmg"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"Chrome.dmg"* ]]
}
@test "scan_installers_in_path (fallback find): finds multiple installer types" {
touch "$HOME/Downloads/App1.dmg"
touch "$HOME/Downloads/App2.pkg"
touch "$HOME/Downloads/App3.iso"
touch "$HOME/Downloads/App.mpkg"
touch "$HOME/Downloads/App1.dmg"
touch "$HOME/Downloads/App2.pkg"
touch "$HOME/Downloads/App3.iso"
touch "$HOME/Downloads/App.mpkg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App1.dmg"* ]]
[[ "$output" == *"App2.pkg"* ]]
[[ "$output" == *"App3.iso"* ]]
[[ "$output" == *"App.mpkg"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"App1.dmg"* ]]
[[ "$output" == *"App2.pkg"* ]]
[[ "$output" == *"App3.iso"* ]]
[[ "$output" == *"App.mpkg"* ]]
}
@test "scan_installers_in_path (fallback find): respects max depth" {
mkdir -p "$HOME/Downloads/level1/level2/level3"
touch "$HOME/Downloads/shallow.dmg"
touch "$HOME/Downloads/level1/mid.dmg"
touch "$HOME/Downloads/level1/level2/deep.dmg"
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
mkdir -p "$HOME/Downloads/level1/level2/level3"
touch "$HOME/Downloads/shallow.dmg"
touch "$HOME/Downloads/level1/mid.dmg"
touch "$HOME/Downloads/level1/level2/deep.dmg"
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
# Default max depth is 2
[[ "$output" == *"shallow.dmg"* ]]
[[ "$output" == *"mid.dmg"* ]]
[[ "$output" == *"deep.dmg"* ]]
[[ "$output" != *"too-deep.dmg"* ]]
[ "$status" -eq 0 ]
# Default max depth is 2
[[ "$output" == *"shallow.dmg"* ]]
[[ "$output" == *"mid.dmg"* ]]
[[ "$output" == *"deep.dmg"* ]]
[[ "$output" != *"too-deep.dmg"* ]]
}
@test "scan_installers_in_path (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
mkdir -p "$HOME/Downloads/level1"
touch "$HOME/Downloads/top.dmg"
touch "$HOME/Downloads/level1/nested.dmg"
mkdir -p "$HOME/Downloads/level1"
touch "$HOME/Downloads/top.dmg"
touch "$HOME/Downloads/level1/nested.dmg"
run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"top.dmg"* ]]
[[ "$output" != *"nested.dmg"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"top.dmg"* ]]
[[ "$output" != *"nested.dmg"* ]]
}
@test "scan_installers_in_path (fallback find): handles non-existent directory" {
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
@test "scan_installers_in_path (fallback find): ignores non-installer files" {
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.jpg"
touch "$HOME/Downloads/archive.tar.gz"
touch "$HOME/Downloads/Installer.dmg"
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.jpg"
touch "$HOME/Downloads/archive.tar.gz"
touch "$HOME/Downloads/Installer.dmg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" != *"document.pdf"* ]]
[[ "$output" != *"image.jpg"* ]]
[[ "$output" != *"archive.tar.gz"* ]]
[[ "$output" == *"Installer.dmg"* ]]
[ "$status" -eq 0 ]
[[ "$output" != *"document.pdf"* ]]
[[ "$output" != *"image.jpg"* ]]
[[ "$output" != *"archive.tar.gz"* ]]
[[ "$output" == *"Installer.dmg"* ]]
}
@test "scan_all_installers: handles missing paths gracefully" {
# Don't create all scan directories, some may not exist
# Only create Downloads, delete others if they exist
rm -rf "$HOME/Desktop"
rm -rf "$HOME/Documents"
rm -rf "$HOME/Public"
rm -rf "$HOME/Public/Downloads"
rm -rf "$HOME/Library/Downloads"
mkdir -p "$HOME/Downloads"
# Don't create all scan directories, some may not exist
# Only create Downloads, delete others if they exist
rm -rf "$HOME/Desktop"
rm -rf "$HOME/Documents"
rm -rf "$HOME/Public"
rm -rf "$HOME/Public/Downloads"
rm -rf "$HOME/Library/Downloads"
mkdir -p "$HOME/Downloads"
# Add an installer to the one directory that exists
touch "$HOME/Downloads/test.dmg"
# Add an installer to the one directory that exists
touch "$HOME/Downloads/test.dmg"
run bash -euo pipefail -c '
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh"
# Should succeed even with missing paths
[ "$status" -eq 0 ]
# Should still find the installer in the existing directory
[[ "$output" == *"test.dmg"* ]]
# Should succeed even with missing paths
[ "$status" -eq 0 ]
# Should still find the installer in the existing directory
[[ "$output" == *"test.dmg"* ]]
}
# Test edge cases
@test "scan_installers_in_path (fallback find): handles filenames with spaces" {
touch "$HOME/Downloads/My App Installer.dmg"
touch "$HOME/Downloads/My App Installer.dmg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"My App Installer.dmg"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"My App Installer.dmg"* ]]
}
@test "scan_installers_in_path (fallback find): handles filenames with special characters" {
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
}
@test "scan_installers_in_path (fallback find): returns empty for directory with no installers" {
# Create some non-installer files
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.png"
# Create some non-installer files
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.png"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
# Symlink handling tests
@test "scan_installers_in_path (fallback find): skips symlinks to regular files" {
touch "$HOME/Downloads/real.dmg"
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
touch "$HOME/Downloads/real.dmg"
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
run env PATH="/usr/bin:/bin" bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"real.dmg"* ]]
[[ "$output" != *"symlink.dmg"* ]]
[[ "$output" != *"dangling.lnk"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"real.dmg"* ]]
[[ "$output" != *"symlink.dmg"* ]]
[[ "$output" != *"dangling.lnk"* ]]
}

View File

@@ -1,35 +1,35 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")"
export HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-purge-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
mkdir -p "$HOME"
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
setup() {
mkdir -p "$HOME/www"
mkdir -p "$HOME/dev"
mkdir -p "$HOME/.cache/mole"
mkdir -p "$HOME/www"
mkdir -p "$HOME/dev"
mkdir -p "$HOME/.cache/mole"
rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/*
rm -rf "${HOME:?}/www"/* "${HOME:?}/dev"/*
}
@test "is_safe_project_artifact: rejects shallow paths (protection against accidents)" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact '$HOME/www/node_modules' '$HOME/www'; then
echo 'UNSAFE'
@@ -37,11 +37,11 @@ setup() {
echo 'SAFE'
fi
")
[[ "$result" == "SAFE" ]]
[[ "$result" == "SAFE" ]]
}
@test "is_safe_project_artifact: allows proper project artifacts" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact '$HOME/www/myproject/node_modules' '$HOME/www'; then
echo 'ALLOWED'
@@ -49,11 +49,11 @@ setup() {
echo 'BLOCKED'
fi
")
[[ "$result" == "ALLOWED" ]]
[[ "$result" == "ALLOWED" ]]
}
@test "is_safe_project_artifact: rejects non-absolute paths" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact 'relative/path/node_modules' '$HOME/www'; then
echo 'UNSAFE'
@@ -61,11 +61,11 @@ setup() {
echo 'SAFE'
fi
")
[[ "$result" == "SAFE" ]]
[[ "$result" == "SAFE" ]]
}
@test "is_safe_project_artifact: validates depth calculation" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact '$HOME/www/project/subdir/node_modules' '$HOME/www'; then
echo 'ALLOWED'
@@ -73,14 +73,14 @@ setup() {
echo 'BLOCKED'
fi
")
[[ "$result" == "ALLOWED" ]]
[[ "$result" == "ALLOWED" ]]
}
@test "is_safe_project_artifact: allows direct child when search path is project root" {
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact '$HOME/single-project/node_modules' '$HOME/single-project'; then
echo 'ALLOWED'
@@ -89,15 +89,15 @@ setup() {
fi
")
[[ "$result" == "ALLOWED" ]]
[[ "$result" == "ALLOWED" ]]
}
@test "is_safe_project_artifact: accepts physical path under symlinked search root" {
mkdir -p "$HOME/www/real/proj/node_modules"
touch "$HOME/www/real/proj/package.json"
ln -s "$HOME/www/real" "$HOME/www/link"
mkdir -p "$HOME/www/real/proj/node_modules"
touch "$HOME/www/real/proj/package.json"
ln -s "$HOME/www/real" "$HOME/www/link"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_safe_project_artifact '$HOME/www/real/proj/node_modules' '$HOME/www/link/proj'; then
echo 'ALLOWED'
@@ -106,43 +106,43 @@ setup() {
fi
")
[[ "$result" == "ALLOWED" ]]
[[ "$result" == "ALLOWED" ]]
}
@test "filter_nested_artifacts: removes nested node_modules" {
mkdir -p "$HOME/www/project/node_modules/package/node_modules"
mkdir -p "$HOME/www/project/node_modules/package/node_modules"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
printf '%s\n' '$HOME/www/project/node_modules' '$HOME/www/project/node_modules/package/node_modules' | \
filter_nested_artifacts | wc -l | tr -d ' '
")
[[ "$result" == "1" ]]
[[ "$result" == "1" ]]
}
@test "filter_nested_artifacts: keeps independent artifacts" {
mkdir -p "$HOME/www/project1/node_modules"
mkdir -p "$HOME/www/project2/target"
mkdir -p "$HOME/www/project1/node_modules"
mkdir -p "$HOME/www/project2/target"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
printf '%s\n' '$HOME/www/project1/node_modules' '$HOME/www/project2/target' | \
filter_nested_artifacts | wc -l | tr -d ' '
")
[[ "$result" == "2" ]]
[[ "$result" == "2" ]]
}
@test "filter_nested_artifacts: removes Xcode build subdirectories (Mac projects)" {
# Simulate Mac Xcode project with nested .build directories:
# ~/www/testapp/build
# ~/www/testapp/build/Framework.build
# ~/www/testapp/build/Package.build
mkdir -p "$HOME/www/testapp/build/Framework.build"
mkdir -p "$HOME/www/testapp/build/Package.build"
# Simulate Mac Xcode project with nested .build directories:
# ~/www/testapp/build
# ~/www/testapp/build/Framework.build
# ~/www/testapp/build/Package.build
mkdir -p "$HOME/www/testapp/build/Framework.build"
mkdir -p "$HOME/www/testapp/build/Package.build"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
printf '%s\n' \
'$HOME/www/testapp/build' \
@@ -150,20 +150,20 @@ setup() {
'$HOME/www/testapp/build/Package.build' | \
filter_nested_artifacts | wc -l | tr -d ' '
")
# Should only keep the top-level 'build' directory, filtering out nested .build dirs
[[ "$result" == "1" ]]
# Should only keep the top-level 'build' directory, filtering out nested .build dirs
[[ "$result" == "1" ]]
}
# Vendor protection unit tests
@test "is_rails_project_root: detects valid Rails project" {
mkdir -p "$HOME/www/test-rails/config"
mkdir -p "$HOME/www/test-rails/bin"
touch "$HOME/www/test-rails/config/application.rb"
touch "$HOME/www/test-rails/Gemfile"
touch "$HOME/www/test-rails/bin/rails"
mkdir -p "$HOME/www/test-rails/config"
mkdir -p "$HOME/www/test-rails/bin"
touch "$HOME/www/test-rails/config/application.rb"
touch "$HOME/www/test-rails/Gemfile"
touch "$HOME/www/test-rails/bin/rails"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_rails_project_root '$HOME/www/test-rails'; then
echo 'YES'
@@ -172,14 +172,14 @@ setup() {
fi
")
[[ "$result" == "YES" ]]
[[ "$result" == "YES" ]]
}
@test "is_rails_project_root: rejects non-Rails directory" {
mkdir -p "$HOME/www/not-rails"
touch "$HOME/www/not-rails/package.json"
mkdir -p "$HOME/www/not-rails"
touch "$HOME/www/not-rails/package.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_rails_project_root '$HOME/www/not-rails'; then
echo 'YES'
@@ -188,14 +188,14 @@ setup() {
fi
")
[[ "$result" == "NO" ]]
[[ "$result" == "NO" ]]
}
@test "is_go_project_root: detects valid Go project" {
mkdir -p "$HOME/www/test-go"
touch "$HOME/www/test-go/go.mod"
mkdir -p "$HOME/www/test-go"
touch "$HOME/www/test-go/go.mod"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_go_project_root '$HOME/www/test-go'; then
echo 'YES'
@@ -204,14 +204,14 @@ setup() {
fi
")
[[ "$result" == "YES" ]]
[[ "$result" == "YES" ]]
}
@test "is_php_project_root: detects valid PHP Composer project" {
mkdir -p "$HOME/www/test-php"
touch "$HOME/www/test-php/composer.json"
mkdir -p "$HOME/www/test-php"
touch "$HOME/www/test-php/composer.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_php_project_root '$HOME/www/test-php'; then
echo 'YES'
@@ -220,17 +220,17 @@ setup() {
fi
")
[[ "$result" == "YES" ]]
[[ "$result" == "YES" ]]
}
@test "is_protected_vendor_dir: protects Rails vendor" {
mkdir -p "$HOME/www/rails-app/vendor"
mkdir -p "$HOME/www/rails-app/config"
touch "$HOME/www/rails-app/config/application.rb"
touch "$HOME/www/rails-app/Gemfile"
touch "$HOME/www/rails-app/config/environment.rb"
mkdir -p "$HOME/www/rails-app/vendor"
mkdir -p "$HOME/www/rails-app/config"
touch "$HOME/www/rails-app/config/application.rb"
touch "$HOME/www/rails-app/Gemfile"
touch "$HOME/www/rails-app/config/environment.rb"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_vendor_dir '$HOME/www/rails-app/vendor'; then
echo 'PROTECTED'
@@ -239,14 +239,14 @@ setup() {
fi
")
[[ "$result" == "PROTECTED" ]]
[[ "$result" == "PROTECTED" ]]
}
@test "is_protected_vendor_dir: does not protect PHP vendor" {
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_vendor_dir '$HOME/www/php-app/vendor'; then
echo 'PROTECTED'
@@ -255,11 +255,11 @@ setup() {
fi
")
[[ "$result" == "NOT_PROTECTED" ]]
[[ "$result" == "NOT_PROTECTED" ]]
}
@test "is_project_container detects project indicators" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
mkdir -p "$HOME/Workspace2/project"
@@ -269,12 +269,12 @@ if is_project_container "$HOME/Workspace2" 2; then
fi
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"yes"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"yes"* ]]
}
@test "discover_project_dirs includes detected containers" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
mkdir -p "$HOME/CustomProjects/app"
@@ -282,22 +282,22 @@ touch "$HOME/CustomProjects/app/go.mod"
discover_project_dirs | grep -q "$HOME/CustomProjects"
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "save_discovered_paths writes config with tilde" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
save_discovered_paths "$HOME/Projects"
grep -q "^~/" "$HOME/.config/mole/purge_paths"
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "select_purge_categories returns failure on empty input" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/clean/project.sh"
if select_purge_categories; then
@@ -305,7 +305,7 @@ if select_purge_categories; then
fi
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "select_purge_categories restores caller EXIT/INT/TERM traps" {
@@ -369,10 +369,10 @@ EOF
}
@test "is_protected_vendor_dir: protects Go vendor" {
mkdir -p "$HOME/www/go-app/vendor"
touch "$HOME/www/go-app/go.mod"
mkdir -p "$HOME/www/go-app/vendor"
touch "$HOME/www/go-app/go.mod"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_vendor_dir '$HOME/www/go-app/vendor'; then
echo 'PROTECTED'
@@ -381,13 +381,13 @@ EOF
fi
")
[[ "$result" == "PROTECTED" ]]
[[ "$result" == "PROTECTED" ]]
}
@test "is_protected_vendor_dir: protects unknown vendor (conservative)" {
mkdir -p "$HOME/www/unknown-app/vendor"
mkdir -p "$HOME/www/unknown-app/vendor"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_vendor_dir '$HOME/www/unknown-app/vendor'; then
echo 'PROTECTED'
@@ -396,14 +396,14 @@ EOF
fi
")
[[ "$result" == "PROTECTED" ]]
[[ "$result" == "PROTECTED" ]]
}
@test "is_protected_purge_artifact: handles vendor directories correctly" {
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_purge_artifact '$HOME/www/php-app/vendor'; then
echo 'PROTECTED'
@@ -412,14 +412,14 @@ EOF
fi
")
# PHP vendor should not be protected
[[ "$result" == "NOT_PROTECTED" ]]
# PHP vendor should not be protected
[[ "$result" == "NOT_PROTECTED" ]]
}
@test "is_protected_purge_artifact: returns false for non-vendor artifacts" {
mkdir -p "$HOME/www/app/node_modules"
mkdir -p "$HOME/www/app/node_modules"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_protected_purge_artifact '$HOME/www/app/node_modules'; then
echo 'PROTECTED'
@@ -428,23 +428,23 @@ EOF
fi
")
# node_modules is not in the protected list
[[ "$result" == "NOT_PROTECTED" ]]
# node_modules is not in the protected list
[[ "$result" == "NOT_PROTECTED" ]]
}
# Integration tests
@test "scan_purge_targets: skips Rails vendor directory" {
mkdir -p "$HOME/www/rails-app/vendor/javascript"
mkdir -p "$HOME/www/rails-app/config"
touch "$HOME/www/rails-app/config/application.rb"
touch "$HOME/www/rails-app/Gemfile"
mkdir -p "$HOME/www/rails-app/bin"
touch "$HOME/www/rails-app/bin/rails"
mkdir -p "$HOME/www/rails-app/vendor/javascript"
mkdir -p "$HOME/www/rails-app/config"
touch "$HOME/www/rails-app/config/application.rb"
touch "$HOME/www/rails-app/Gemfile"
mkdir -p "$HOME/www/rails-app/bin"
touch "$HOME/www/rails-app/bin/rails"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/rails-app/vendor' '$scan_output'; then
@@ -454,19 +454,19 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "SKIPPED" ]]
[[ "$result" == "SKIPPED" ]]
}
@test "scan_purge_targets: cleans PHP Composer vendor directory" {
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
mkdir -p "$HOME/www/php-app/vendor"
touch "$HOME/www/php-app/composer.json"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/php-app/vendor' '$scan_output'; then
@@ -476,20 +476,20 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "FOUND" ]]
[[ "$result" == "FOUND" ]]
}
@test "scan_purge_targets: skips Go vendor directory" {
mkdir -p "$HOME/www/go-app/vendor"
touch "$HOME/www/go-app/go.mod"
touch "$HOME/www/go-app/go.sum"
mkdir -p "$HOME/www/go-app/vendor"
touch "$HOME/www/go-app/go.mod"
touch "$HOME/www/go-app/go.sum"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/go-app/vendor' '$scan_output'; then
@@ -499,19 +499,19 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "SKIPPED" ]]
[[ "$result" == "SKIPPED" ]]
}
@test "scan_purge_targets: skips unknown vendor directory" {
# Create a vendor directory without any project file
mkdir -p "$HOME/www/unknown-app/vendor"
# Create a vendor directory without any project file
mkdir -p "$HOME/www/unknown-app/vendor"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/unknown-app/vendor' '$scan_output'; then
@@ -521,20 +521,20 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
# Unknown vendor should be protected (conservative approach)
[[ "$result" == "SKIPPED" ]]
# Unknown vendor should be protected (conservative approach)
[[ "$result" == "SKIPPED" ]]
}
@test "scan_purge_targets: finds direct-child artifacts in project root with find mode" {
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
MO_USE_FIND=1 scan_purge_targets '$HOME/single-project' '$scan_output'
if grep -q '$HOME/single-project/node_modules' '$scan_output'; then
@@ -544,19 +544,19 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "FOUND" ]]
[[ "$result" == "FOUND" ]]
}
@test "scan_purge_targets: supports trailing slash search path in find mode" {
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
mkdir -p "$HOME/single-project/node_modules"
touch "$HOME/single-project/package.json"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
MO_USE_FIND=1 scan_purge_targets '$HOME/single-project/' '$scan_output'
if grep -q '$HOME/single-project/node_modules' '$scan_output'; then
@@ -566,16 +566,16 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "FOUND" ]]
[[ "$result" == "FOUND" ]]
}
@test "is_recently_modified: detects recent projects" {
mkdir -p "$HOME/www/project/node_modules"
touch "$HOME/www/project/package.json"
mkdir -p "$HOME/www/project/node_modules"
touch "$HOME/www/project/package.json"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_recently_modified '$HOME/www/project/node_modules'; then
@@ -584,66 +584,66 @@ EOF
echo 'OLD'
fi
")
[[ "$result" == "RECENT" ]]
[[ "$result" == "RECENT" ]]
}
@test "is_recently_modified: marks old projects correctly" {
mkdir -p "$HOME/www/old-project/node_modules"
mkdir -p "$HOME/www/old-project"
mkdir -p "$HOME/www/old-project/node_modules"
mkdir -p "$HOME/www/old-project"
bash -c "
bash -c "
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/project.sh'
is_recently_modified '$HOME/www/old-project/node_modules' || true
"
local exit_code=$?
[ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ]
local exit_code=$?
[ "$exit_code" -eq 0 ] || [ "$exit_code" -eq 1 ]
}
@test "purge targets are configured correctly" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
echo \"\${PURGE_TARGETS[@]}\"
")
[[ "$result" == *"node_modules"* ]]
[[ "$result" == *"target"* ]]
[[ "$result" == *"node_modules"* ]]
[[ "$result" == *"target"* ]]
}
@test "get_dir_size_kb: calculates directory size" {
mkdir -p "$HOME/www/test-project/node_modules"
dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null
mkdir -p "$HOME/www/test-project/node_modules"
dd if=/dev/zero of="$HOME/www/test-project/node_modules/file.bin" bs=1024 count=1024 2>/dev/null
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
get_dir_size_kb '$HOME/www/test-project/node_modules'
")
[[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]]
[[ "$result" -ge 1000 ]] && [[ "$result" -le 1100 ]]
}
@test "get_dir_size_kb: handles non-existent paths gracefully" {
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
get_dir_size_kb '$HOME/www/non-existent'
")
[[ "$result" == "0" ]]
[[ "$result" == "0" ]]
}
@test "get_dir_size_kb: returns TIMEOUT when size calculation hangs" {
mkdir -p "$HOME/www/stuck-project/node_modules"
mkdir -p "$HOME/www/stuck-project/node_modules"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/project.sh'
run_with_timeout() { return 124; }
get_dir_size_kb '$HOME/www/stuck-project/node_modules'
")
[[ "$result" == "TIMEOUT" ]]
[[ "$result" == "TIMEOUT" ]]
}
@test "clean_project_artifacts: restores caller INT/TERM traps" {
result=$(bash -c "
result=$(bash -c "
set -euo pipefail
export HOME='$HOME'
source '$PROJECT_ROOT/lib/core/common.sh'
@@ -669,92 +669,108 @@ EOF
fi
")
[[ "$result" == *"PASS"* ]]
[[ "$result" == *"PASS"* ]]
}
@test "clean_project_artifacts: handles empty directory gracefully" {
run bash -c "
run bash -c "
export HOME='$HOME'
source '$PROJECT_ROOT/lib/core/common.sh'
source '$PROJECT_ROOT/lib/clean/project.sh'
clean_project_artifacts
" < /dev/null
" </dev/null
[[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]]
[[ "$status" -eq 0 ]] || [[ "$status" -eq 2 ]]
}
@test "clean_project_artifacts: scans and finds artifacts" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
mkdir -p "$HOME/www/test-project/node_modules/package1"
echo "test data" > "$HOME/www/test-project/node_modules/package1/index.js"
mkdir -p "$HOME/www/test-project/node_modules/package1"
echo "test data" >"$HOME/www/test-project/node_modules/package1/index.js"
mkdir -p "$HOME/www/test-project"
mkdir -p "$HOME/www/test-project"
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
run bash -c "
run bash -c "
export HOME='$HOME'
$timeout_cmd 5 '$PROJECT_ROOT/bin/purge.sh' 2>&1 < /dev/null || true
"
[[ "$output" =~ "Scanning" ]] ||
[[ "$output" =~ "Purge complete" ]] ||
[[ "$output" =~ "No old" ]] ||
[[ "$output" =~ "Great" ]]
[[ "$output" =~ "Scanning" ]] ||
[[ "$output" =~ "Purge complete" ]] ||
[[ "$output" =~ "No old" ]] ||
[[ "$output" =~ "Great" ]]
}
@test "mo purge: command exists and is executable" {
[ -x "$PROJECT_ROOT/mole" ]
[ -f "$PROJECT_ROOT/bin/purge.sh" ]
[ -x "$PROJECT_ROOT/mole" ]
[ -f "$PROJECT_ROOT/bin/purge.sh" ]
}
@test "mo purge: shows in help text" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ]
[[ "$output" == *"mo purge"* ]]
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ]
[[ "$output" == *"mo purge"* ]]
}
@test "mo purge: accepts --debug flag" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
run bash -c "
run bash -c "
export HOME='$HOME'
$timeout_cmd 2 '$PROJECT_ROOT/mole' purge --debug < /dev/null 2>&1 || true
"
true
true
}
@test "mo purge: accepts --dry-run flag" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
run bash -c "
export HOME='$HOME'
$timeout_cmd 2 '$PROJECT_ROOT/mole' purge --dry-run < /dev/null 2>&1 || true
"
[[ "$output" == *"DRY RUN MODE"* ]] || [[ "$output" == *"Dry run complete"* ]]
}
@test "mo purge: creates cache directory for stats" {
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then
skip "gtimeout/timeout not available"
fi
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
timeout_cmd="timeout"
command -v timeout >/dev/null 2>&1 || timeout_cmd="gtimeout"
bash -c "
bash -c "
export HOME='$HOME'
$timeout_cmd 2 '$PROJECT_ROOT/mole' purge < /dev/null 2>&1 || true
"
[ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ]
[ -d "$HOME/.cache/mole" ] || [ -d "${XDG_CACHE_HOME:-$HOME/.cache}/mole" ]
}
# .NET bin directory detection tests
@test "is_dotnet_bin_dir: finds .NET context in parent directory with Debug dir" {
mkdir -p "$HOME/www/dotnet-app/bin/Debug"
touch "$HOME/www/dotnet-app/MyProject.csproj"
mkdir -p "$HOME/www/dotnet-app/bin/Debug"
touch "$HOME/www/dotnet-app/MyProject.csproj"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then
echo 'FOUND'
@@ -763,14 +779,14 @@ EOF
fi
")
[[ "$result" == "FOUND" ]]
[[ "$result" == "FOUND" ]]
}
@test "is_dotnet_bin_dir: requires .csproj AND Debug/Release" {
mkdir -p "$HOME/www/dotnet-app/bin"
touch "$HOME/www/dotnet-app/MyProject.csproj"
mkdir -p "$HOME/www/dotnet-app/bin"
touch "$HOME/www/dotnet-app/MyProject.csproj"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_dotnet_bin_dir '$HOME/www/dotnet-app/bin'; then
echo 'FOUND'
@@ -779,15 +795,15 @@ EOF
fi
")
# Should not find it because Debug/Release directories don't exist
[[ "$result" == "NOT_FOUND" ]]
# Should not find it because Debug/Release directories don't exist
[[ "$result" == "NOT_FOUND" ]]
}
@test "is_dotnet_bin_dir: rejects non-bin directories" {
mkdir -p "$HOME/www/dotnet-app/obj"
touch "$HOME/www/dotnet-app/MyProject.csproj"
mkdir -p "$HOME/www/dotnet-app/obj"
touch "$HOME/www/dotnet-app/MyProject.csproj"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
if is_dotnet_bin_dir '$HOME/www/dotnet-app/obj'; then
echo 'FOUND'
@@ -795,19 +811,18 @@ EOF
echo 'NOT_FOUND'
fi
")
[[ "$result" == "NOT_FOUND" ]]
[[ "$result" == "NOT_FOUND" ]]
}
# Integration test for bin scanning
@test "scan_purge_targets: includes .NET bin directories with Debug/Release" {
mkdir -p "$HOME/www/dotnet-app/bin/Debug"
touch "$HOME/www/dotnet-app/MyProject.csproj"
mkdir -p "$HOME/www/dotnet-app/bin/Debug"
touch "$HOME/www/dotnet-app/MyProject.csproj"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/dotnet-app/bin' '$scan_output'; then
@@ -817,19 +832,19 @@ EOF
fi
")
rm -f "$scan_output"
rm -f "$scan_output"
[[ "$result" == "FOUND" ]]
[[ "$result" == "FOUND" ]]
}
@test "scan_purge_targets: skips generic bin directories (non-.NET)" {
mkdir -p "$HOME/www/ruby-app/bin"
touch "$HOME/www/ruby-app/Gemfile"
mkdir -p "$HOME/www/ruby-app/bin"
touch "$HOME/www/ruby-app/Gemfile"
local scan_output
scan_output="$(mktemp)"
local scan_output
scan_output="$(mktemp)"
result=$(bash -c "
result=$(bash -c "
source '$PROJECT_ROOT/lib/clean/project.sh'
scan_purge_targets '$HOME/www' '$scan_output'
if grep -q '$HOME/www/ruby-app/bin' '$scan_output'; then
@@ -839,6 +854,6 @@ EOF
fi
")
rm -f "$scan_output"
[[ "$result" == "SKIPPED" ]]
rm -f "$scan_output"
[[ "$result" == "SKIPPED" ]]
}

View File

@@ -1,67 +1,67 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats
if [[ -z "$ORIGINAL_HOME" ]]; then
ORIGINAL_HOME="${HOME:-}"
fi
export ORIGINAL_HOME
ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats
if [[ -z "$ORIGINAL_HOME" ]]; then
ORIGINAL_HOME="${HOME:-}"
fi
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")"
export 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
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
setup() {
export TERM="dumb"
rm -rf "${HOME:?}"/*
mkdir -p "$HOME"
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"
mkdir -p "$HOME/Library/LaunchAgents"
touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist"
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"
mkdir -p "$HOME/Library/LaunchAgents"
touch "$HOME/Library/LaunchAgents/com.example.TestApp.plist"
}
@test "find_app_files discovers user-level leftovers" {
create_app_artifacts
create_app_artifacts
result="$(
HOME="$HOME" bash --noprofile --norc << 'EOF'
result="$(
HOME="$HOME" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/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"* ]]
[[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]]
[[ "$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"* ]]
[[ "$result" == *"LaunchAgents/com.example.TestApp.plist"* ]]
}
@test "get_diagnostic_report_paths_for_app avoids executable prefix collisions" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
@@ -92,16 +92,16 @@ result=$(get_diagnostic_report_paths_for_app "$app_dir" "Foo" "$diag_dir")
[[ "$result" != *"Foobar_2026-01-01-120001_host.ips"* ]] || exit 1
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@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
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'
result="$(
HOME="$HOME" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
files="$(printf '%s
@@ -109,15 +109,15 @@ files="$(printf '%s
' "$HOME/sized/file1" "$HOME/sized/file2")"
calculate_total_size "$files"
EOF
)"
)"
[ "$result" -ge 3 ]
[ "$result" -ge 3 ]
}
@test "batch_uninstall_applications removes selected app data" {
create_app_artifacts
create_app_artifacts
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -155,22 +155,22 @@ batch_uninstall_applications
[[ ! -f "$HOME/Library/LaunchAgents/com.example.TestApp.plist" ]] || exit 1
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "batch_uninstall_applications preview shows full related file list" {
mkdir -p "$HOME/Applications/TestApp.app"
mkdir -p "$HOME/Library/Application Support/TestApp"
mkdir -p "$HOME/Library/Caches/TestApp"
mkdir -p "$HOME/Library/Logs/TestApp"
touch "$HOME/Library/Logs/TestApp/log1.log"
touch "$HOME/Library/Logs/TestApp/log2.log"
touch "$HOME/Library/Logs/TestApp/log3.log"
touch "$HOME/Library/Logs/TestApp/log4.log"
touch "$HOME/Library/Logs/TestApp/log5.log"
touch "$HOME/Library/Logs/TestApp/log6.log"
mkdir -p "$HOME/Applications/TestApp.app"
mkdir -p "$HOME/Library/Application Support/TestApp"
mkdir -p "$HOME/Library/Caches/TestApp"
mkdir -p "$HOME/Library/Logs/TestApp"
touch "$HOME/Library/Logs/TestApp/log1.log"
touch "$HOME/Library/Logs/TestApp/log2.log"
touch "$HOME/Library/Logs/TestApp/log3.log"
touch "$HOME/Library/Logs/TestApp/log4.log"
touch "$HOME/Library/Logs/TestApp/log5.log"
touch "$HOME/Library/Logs/TestApp/log6.log"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -210,28 +210,27 @@ total_size_cleaned=0
printf 'q' | batch_uninstall_applications
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]]
[[ "$output" != *"more files"* ]]
[ "$status" -eq 0 ]
[[ "$output" == *"~/Library/Logs/TestApp/log6.log"* ]]
[[ "$output" != *"more files"* ]]
}
@test "safe_remove can remove a simple directory" {
mkdir -p "$HOME/test_dir"
touch "$HOME/test_dir/file.txt"
mkdir -p "$HOME/test_dir"
touch "$HOME/test_dir/file.txt"
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
safe_remove "$HOME/test_dir"
[[ ! -d "$HOME/test_dir" ]] || exit 1
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "decode_file_list validates base64 encoding" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -242,11 +241,11 @@ result=$(decode_file_list "$valid_data" "TestApp")
[[ -n "$result" ]] || exit 1
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "decode_file_list rejects invalid base64" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -258,11 +257,11 @@ else
fi
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "decode_file_list handles empty input" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -272,11 +271,11 @@ result=$(decode_file_list "$empty_data" "TestApp" 2>/dev/null) || true
[[ -z "$result" ]]
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "decode_file_list rejects non-absolute paths" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -289,11 +288,11 @@ else
fi
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@test "decode_file_list handles both BSD and GNU base64 formats" {
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF'
run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF'
set -euo pipefail
source "$PROJECT_ROOT/lib/core/common.sh"
source "$PROJECT_ROOT/lib/uninstall/batch.sh"
@@ -311,16 +310,16 @@ result=$(decode_file_list "$encoded_data" "TestApp")
[[ -n "$result" ]] || exit 1
EOF
[ "$status" -eq 0 ]
[ "$status" -eq 0 ]
}
@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"
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'
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() { :; }
@@ -355,9 +354,31 @@ export -f start_inline_spinner stop_inline_spinner rm sudo
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" ]
[ "$status" -eq 0 ]
[ ! -f "$HOME/.local/bin/mole" ]
[ ! -f "$HOME/.local/bin/mo" ]
[ ! -d "$HOME/.config/mole" ]
[ ! -d "$HOME/.cache/mole" ]
}
@test "remove_mole dry-run keeps 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 --dry-run
EOF
[ "$status" -eq 0 ]
[[ "$output" == *"DRY RUN MODE"* ]]
[ -f "$HOME/.local/bin/mole" ]
[ -f "$HOME/.local/bin/mo" ]
[ -d "$HOME/.config/mole" ]
[ -d "$HOME/.cache/mole" ]
}