1
0
mirror of https://github.com/tw93/Mole.git synced 2026-03-22 18:30:08 +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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 533 KiB

After

Width:  |  Height:  |  Size: 541 KiB

View File

@@ -68,6 +68,12 @@ mo clean --dry-run --debug # Detailed preview with risk levels and file info
mo optimize --dry-run # Preview optimization actions mo optimize --dry-run # Preview optimization actions
mo optimize --debug # Run with detailed operation logs mo optimize --debug # Run with detailed operation logs
mo optimize --whitelist # Manage protected optimization rules mo optimize --whitelist # Manage protected optimization rules
mo uninstall --dry-run # Preview app uninstall actions
mo purge --dry-run # Preview project artifact purge
mo installer --dry-run # Preview installer cleanup actions
mo touchid enable --dry-run # Preview Touch ID sudo config changes
mo completion --dry-run # Preview shell completion file updates
mo remove --dry-run # Preview Mole self-removal
mo purge --paths # Configure project scan directories mo purge --paths # Configure project scan directories
mo analyze /Volumes # Analyze external drives only mo analyze /Volumes # Analyze external drives only
``` ```
@@ -75,7 +81,7 @@ mo analyze /Volumes # Analyze external drives only
## Tips ## Tips
- Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室. - Video tutorial: Watch the [Mole tutorial video](https://www.youtube.com/watch?v=UEe9-w4CcQ0), thanks to PAPAYA 電腦教室.
- Safety first: Deletions are permanent. Review carefully and preview with `mo clean --dry-run`. See [Security Audit](SECURITY_AUDIT.md). - Safety first: Deletions are permanent. Review carefully with dry-run before applying changes. See [Security Audit](SECURITY_AUDIT.md).
- Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`. - Debug and logs: Use `--debug` for detailed logs. Combine with `--dry-run` for a full preview. File operations are logged to `~/.config/mole/operations.log`. Disable with `MO_NO_OPLOG=1`.
- Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`. - Navigation: Mole supports arrow keys and Vim bindings `h/j/k/l`.

View File

@@ -32,8 +32,33 @@ emit_fish_completions() {
printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd" printf 'complete -c %s -n "not __fish_mole_no_subcommand" -a fish -d "generate fish completion" -n "__fish_see_subcommand_path completion"\n' "$cmd"
} }
DRY_RUN_MODE=false
if [[ $# -gt 0 ]]; then
normalized_args=()
for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
DRY_RUN_MODE=true
;;
*)
normalized_args+=("$arg")
;;
esac
done
if [[ ${#normalized_args[@]} -gt 0 ]]; then
set -- "${normalized_args[@]}"
else
set --
fi
fi
# Auto-install mode when run without arguments # Auto-install mode when run without arguments
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, shell config files will not be modified"
echo ""
fi
# Detect current shell # Detect current shell
current_shell="${SHELL##*/}" current_shell="${SHELL##*/}"
if [[ -z "$current_shell" ]]; then if [[ -z "$current_shell" ]]; then
@@ -73,16 +98,21 @@ if [[ $# -eq 0 ]]; then
if [[ -z "$completion_name" ]]; then if [[ -z "$completion_name" ]]; then
if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then if [[ -f "$config_file" ]] && grep -Eq "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" 2> /dev/null; then
original_mode="" if [[ "$DRY_RUN_MODE" == "true" ]]; then
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would remove stale completion entries from $config_file${NC}"
temp_file="$(mktemp)" echo ""
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true else
mv "$temp_file" "$config_file" original_mode=""
if [[ -n "$original_mode" ]]; then original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
chmod "$original_mode" "$config_file" 2> /dev/null || true temp_file="$(mktemp)"
grep -Ev "(^# Mole shell completion$|(mole|mo)[[:space:]]+completion)" "$config_file" > "$temp_file" || true
mv "$temp_file" "$config_file"
if [[ -n "$original_mode" ]]; then
chmod "$original_mode" "$config_file" 2> /dev/null || true
fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo ""
fi fi
echo -e "${GREEN}${ICON_SUCCESS}${NC} Removed stale completion entries from $config_file"
echo ""
fi fi
log_error "mole not found in PATH, install Mole before enabling completion" log_error "mole not found in PATH, install Mole before enabling completion"
exit 1 exit 1
@@ -90,6 +120,12 @@ if [[ $# -eq 0 ]]; then
# Check if already installed and normalize to latest line # Check if already installed and normalize to latest line
if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then if [[ -f "$config_file" ]] && grep -Eq "(mole|mo)[[:space:]]+completion" "$config_file" 2> /dev/null; then
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${GRAY}${ICON_REVIEW} [DRY RUN] Would normalize completion entry in $config_file${NC}"
echo ""
exit 0
fi
original_mode="" original_mode=""
original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)" original_mode="$(stat -f '%Mp%Lp' "$config_file" 2> /dev/null || true)"
temp_file="$(mktemp)" temp_file="$(mktemp)"
@@ -114,6 +150,11 @@ if [[ $# -eq 0 ]]; then
echo -e "${GRAY}Will add to ${config_file}:${NC}" echo -e "${GRAY}Will add to ${config_file}:${NC}"
echo " $completion_line" echo " $completion_line"
echo "" echo ""
if [[ "$DRY_RUN_MODE" == "true" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
fi
echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: " echo -ne "${PURPLE}${ICON_ARROW}${NC} Enable completion for ${GREEN}${current_shell}${NC}? ${GRAY}Enter confirm / Q cancel${NC}: "
IFS= read -r -s -n1 key || key="" IFS= read -r -s -n1 key || key=""
drain_pending_input drain_pending_input
@@ -227,6 +268,7 @@ Setup shell tab completion for mole and mo commands.
Auto-install: Auto-install:
mole completion # Auto-detect shell and install mole completion # Auto-detect shell and install
mole completion --dry-run # Preview config changes without writing files
Manual install: Manual install:
mole completion bash # Generate bash completion script mole completion bash # Generate bash completion script

View File

@@ -650,13 +650,22 @@ perform_installers() {
show_summary() { show_summary() {
local summary_heading="Installers cleaned" local summary_heading="Installers cleaned"
local -a summary_details=() local -a summary_details=()
local dry_run_mode="${MOLE_DRY_RUN:-0}"
if [[ "$dry_run_mode" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_deleted -gt 0 ]]; then if [[ $total_deleted -gt 0 ]]; then
local freed_mb local freed_mb
freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}')
summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}") if [[ "$dry_run_mode" == "1" ]]; then
summary_details+=("Your Mac is cleaner now!") summary_details+=("Would remove ${GREEN}$total_deleted${NC} installers, free ${GREEN}${freed_mb}MB${NC}")
else
summary_details+=("Removed ${GREEN}$total_deleted${NC} installers, freed ${GREEN}${freed_mb}MB${NC}")
summary_details+=("Your Mac is cleaner now!")
fi
else else
summary_details+=("No installers were removed") summary_details+=("No installers were removed")
fi fi
@@ -675,6 +684,9 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*) *)
echo "Unknown option: $arg" echo "Unknown option: $arg"
exit 1 exit 1
@@ -682,6 +694,11 @@ main() {
esac esac
done done
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No installer files will be removed"
printf '\n'
fi
hide_cursor hide_cursor
perform_installers perform_installers
local exit_code=$? local exit_code=$?

View File

@@ -205,11 +205,18 @@ perform_purge() {
rm -f "$stats_dir/purge_count" rm -f "$stats_dir/purge_count"
fi fi
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_heading="Dry run complete - no changes made"
fi
if [[ $total_size_cleaned -gt 0 ]]; then if [[ $total_size_cleaned -gt 0 ]]; then
local freed_gb local freed_gb
freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}')
local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}" local summary_line="Space freed: ${GREEN}${freed_gb}GB${NC}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
summary_line="Would free: ${GREEN}${freed_gb}GB${NC}"
fi
[[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned" [[ $total_items_cleaned -gt 0 ]] && summary_line+=" | Items: $total_items_cleaned"
summary_line+=" | Free: $(get_free_space)" summary_line+=" | Free: $(get_free_space)"
summary_details+=("$summary_line") summary_details+=("$summary_line")
@@ -233,6 +240,7 @@ show_help() {
echo "" echo ""
echo -e "${YELLOW}Options:${NC}" echo -e "${YELLOW}Options:${NC}"
echo " --paths Edit custom scan directories" echo " --paths Edit custom scan directories"
echo " --dry-run Preview purge actions without making changes"
echo " --debug Enable debug logging" echo " --debug Enable debug logging"
echo " --help Show this help message" echo " --help Show this help message"
echo "" echo ""
@@ -262,6 +270,9 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
*) *)
echo "Unknown option: $arg" echo "Unknown option: $arg"
echo "Use 'mo purge --help' for usage information" echo "Use 'mo purge --help' for usage information"
@@ -271,6 +282,10 @@ main() {
done done
start_purge start_purge
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No project artifacts will be removed"
printf '\n'
fi
hide_cursor hide_cursor
perform_purge perform_purge
show_cursor show_cursor

View File

@@ -60,6 +60,10 @@ supports_touchid() {
return 1 return 1
} }
touchid_dry_run_enabled() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# Show current Touch ID status # Show current Touch ID status
show_status() { show_status() {
if is_touchid_configured; then if is_touchid_configured; then
@@ -74,6 +78,16 @@ enable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if is_touchid_configured; then
echo -e "${GREEN}${ICON_SUCCESS} Touch ID is already enabled, no changes needed${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would enable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
# First check if system supports Touch ID # First check if system supports Touch ID
if ! supports_touchid; then if ! supports_touchid; then
log_warning "This Mac may not support Touch ID" log_warning "This Mac may not support Touch ID"
@@ -201,6 +215,16 @@ disable_touchid() {
# Cleanup trap handled by global EXIT trap # Cleanup trap handled by global EXIT trap
local temp_file="" local temp_file=""
if touchid_dry_run_enabled; then
if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS} [DRY RUN] Would disable Touch ID for sudo${NC}"
echo -e "${GRAY}${ICON_REVIEW} Target files: ${PAM_SUDO_FILE} and/or ${PAM_SUDO_LOCAL_FILE}${NC}"
fi
return 0
fi
if ! is_touchid_configured; then if ! is_touchid_configured; then
echo -e "${YELLOW}Touch ID is not currently enabled${NC}" echo -e "${YELLOW}Touch ID is not currently enabled${NC}"
return 0 return 0
@@ -303,12 +327,39 @@ show_menu() {
# Main # Main
main() { main() {
local command="${1:-}" local command=""
local arg
for arg in "$@"; do
case "$arg" in
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
"--help" | "-h")
show_touchid_help
return 0
;;
enable | disable | status)
if [[ -z "$command" ]]; then
command="$arg"
else
log_error "Only one touchid command is supported per run"
return 1
fi
;;
*)
log_error "Unknown command: $arg"
return 1
;;
esac
done
if touchid_dry_run_enabled; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No sudo authentication files will be modified"
echo ""
fi
case "$command" in case "$command" in
"--help" | "-h")
show_touchid_help
;;
enable) enable)
enable_touchid enable_touchid
;; ;;

View File

@@ -822,10 +822,17 @@ main() {
"--debug") "--debug")
export MO_DEBUG=1 export MO_DEBUG=1
;; ;;
"--dry-run" | "-n")
export MOLE_DRY_RUN=1
;;
esac esac
done done
hide_cursor hide_cursor
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified"
printf '\n'
fi
local first_scan=true local first_scan=true
while true; do while true; do

View File

@@ -1367,6 +1367,7 @@ clean_project_artifacts() {
echo "" echo ""
local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole" local stats_dir="${XDG_CACHE_HOME:-$HOME/.cache}/mole"
local cleaned_count=0 local cleaned_count=0
local dry_run_mode="${MOLE_DRY_RUN:-0}"
for idx in "${selected_indices[@]}"; do for idx in "${selected_indices[@]}"; do
local item_path="${item_paths[idx]}" local item_path="${item_paths[idx]}"
local artifact_type=$(basename "$item_path") local artifact_type=$(basename "$item_path")
@@ -1388,7 +1389,7 @@ clean_project_artifacts() {
fi fi
if [[ -e "$item_path" ]]; then if [[ -e "$item_path" ]]; then
safe_remove "$item_path" true safe_remove "$item_path" true
if [[ ! -e "$item_path" ]]; then if [[ "$dry_run_mode" == "1" || ! -e "$item_path" ]]; then
local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0") local current_total=$(cat "$stats_dir/purge_stats" 2> /dev/null || echo "0")
echo "$((current_total + size_kb))" > "$stats_dir/purge_stats" echo "$((current_total + size_kb))" > "$stats_dir/purge_stats"
cleaned_count=$((cleaned_count + 1)) cleaned_count=$((cleaned_count + 1))
@@ -1396,7 +1397,11 @@ clean_project_artifacts() {
fi fi
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
stop_inline_spinner stop_inline_spinner
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}" if [[ "$dry_run_mode" == "1" ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} [DRY RUN] $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
else
echo -e "${GREEN}${ICON_SUCCESS}${NC} $project_path, $artifact_type${NC}, ${GREEN}$size_human${NC}"
fi
fi fi
done done
# Update count # Update count

View File

@@ -1419,6 +1419,11 @@ force_kill_app() {
local app_name="$1" local app_name="$1"
local app_path="${2:-""}" local app_path="${2:-""}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would terminate running app: $app_name"
return 0
fi
# Get the executable name from bundle if app_path is provided # Get the executable name from bundle if app_path is provided
local exec_name="" local exec_name=""
if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then if [[ -n "$app_path" && -e "$app_path/Contents/Info.plist" ]]; then

View File

@@ -18,6 +18,7 @@ show_installer_help() {
echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)." echo "Find and remove installer files (.dmg, .pkg, .iso, .xip, .zip)."
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview installer cleanup without making changes"
echo " --debug Show detailed operation logs" echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
} }
@@ -45,6 +46,7 @@ show_touchid_help() {
echo " status Show current Touch ID status" echo " status Show current Touch ID status"
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview Touch ID changes without modifying sudo config"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
echo "" echo ""
echo "If no command is provided, an interactive menu is shown." echo "If no command is provided, an interactive menu is shown."
@@ -56,6 +58,7 @@ show_uninstall_help() {
echo "Interactively remove applications and their leftover files." echo "Interactively remove applications and their leftover files."
echo "" echo ""
echo "Options:" echo "Options:"
echo " --dry-run Preview app uninstallation without making changes"
echo " --debug Show detailed operation logs" echo " --debug Show detailed operation logs"
echo " -h, --help Show this help message" echo " -h, --help Show this help message"
} }

View File

@@ -11,6 +11,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Batch uninstall with a single confirmation. # Batch uninstall with a single confirmation.
get_lsregister_path() {
echo "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
}
is_uninstall_dry_run() {
[[ "${MOLE_DRY_RUN:-0}" == "1" ]]
}
# High-performance sensitive data detection (pure Bash, no subprocess) # High-performance sensitive data detection (pure Bash, no subprocess)
# Faster than grep for batch operations, especially when processing many apps # Faster than grep for batch operations, especially when processing many apps
has_sensitive_data() { has_sensitive_data() {
@@ -77,6 +85,11 @@ stop_launch_services() {
local bundle_id="$1" local bundle_id="$1"
local has_system_files="${2:-false}" local has_system_files="${2:-false}"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would unload launch services for bundle: $bundle_id"
return 0
fi
[[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0 [[ -z "$bundle_id" || "$bundle_id" == "unknown" ]] && return 0
# Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app) # Validate bundle_id format: must be reverse-DNS style (e.g., com.example.app)
@@ -152,6 +165,11 @@ remove_login_item() {
local app_name="$1" local app_name="$1"
local bundle_id="$2" local bundle_id="$2"
if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would remove login item: ${app_name:-$bundle_id}"
return 0
fi
# Skip if no identifiers provided # Skip if no identifiers provided
[[ -z "$app_name" && -z "$bundle_id" ]] && return 0 [[ -z "$app_name" && -z "$bundle_id" ]] && return 0
@@ -201,7 +219,12 @@ remove_file_list() {
safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true safe_remove_symlink "$file" "$use_sudo" && ((++count)) || true
else else
if [[ "$use_sudo" == "true" ]]; then if [[ "$use_sudo" == "true" ]]; then
safe_sudo_remove "$file" && ((++count)) || true if is_uninstall_dry_run; then
debug_log "[DRY RUN] Would sudo remove: $file"
((++count))
else
safe_sudo_remove "$file" && ((++count)) || true
fi
else else
safe_remove "$file" true && ((++count)) || true safe_remove "$file" true && ((++count)) || true
fi fi
@@ -437,7 +460,7 @@ batch_uninstall_applications() {
export MOLE_UNINSTALL_MODE=1 export MOLE_UNINSTALL_MODE=1
# Request sudo if needed. # Request sudo if needed.
if [[ ${#sudo_apps[@]} -gt 0 ]]; then if [[ ${#sudo_apps[@]} -gt 0 && "${MOLE_DRY_RUN:-0}" != "1" ]]; then
if ! sudo -n true 2> /dev/null; then if ! sudo -n true 2> /dev/null; then
if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then
echo "" echo ""
@@ -547,12 +570,18 @@ batch_uninstall_applications() {
fi fi
fi fi
else else
local ret=0 if is_uninstall_dry_run; then
safe_sudo_remove "$app_path" || ret=$? if ! safe_remove "$app_path" true; then
if [[ $ret -ne 0 ]]; then reason="dry-run path validation failed"
local diagnosis fi
diagnosis=$(diagnose_removal_failure "$ret" "$app_name") else
IFS='|' read -r reason suggestion <<< "$diagnosis" local ret=0
safe_sudo_remove "$app_path" || ret=$?
if [[ $ret -ne 0 ]]; then
local diagnosis
diagnosis=$(diagnose_removal_failure "$ret" "$app_name")
IFS='|' read -r reason suggestion <<< "$diagnosis"
fi
fi fi
fi fi
else else
@@ -583,10 +612,14 @@ batch_uninstall_applications() {
remove_file_list "$system_all" "true" > /dev/null remove_file_list "$system_all" "true" > /dev/null
fi fi
# Clean up macOS defaults (preference domains). # Defaults writes are side effects that should never run in dry-run mode.
if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]]; then
if defaults read "$bundle_id" &> /dev/null; then if is_uninstall_dry_run; then
defaults delete "$bundle_id" 2> /dev/null || true debug_log "[DRY RUN] Would clear defaults domain: $bundle_id"
else
if defaults read "$bundle_id" &> /dev/null; then
defaults delete "$bundle_id" 2> /dev/null || true
fi
fi fi
# ByHost preferences (machine-specific). # ByHost preferences (machine-specific).
@@ -644,8 +677,15 @@ batch_uninstall_applications() {
local success_text="app" local success_text="app"
[[ $success_count -gt 1 ]] && success_text="apps" [[ $success_count -gt 1 ]] && success_text="apps"
local success_line="Removed ${success_count} ${success_text}" local success_line="Removed ${success_count} ${success_text}"
if is_uninstall_dry_run; then
success_line="Would remove ${success_count} ${success_text}"
fi
if [[ -n "$freed_display" ]]; then if [[ -n "$freed_display" ]]; then
success_line+=", freed ${GREEN}${freed_display}${NC}" if is_uninstall_dry_run; then
success_line+=", would free ${GREEN}${freed_display}${NC}"
else
success_line+=", freed ${GREEN}${freed_display}${NC}"
fi
fi fi
# Format app list with max 3 per line. # Format app list with max 3 per line.
@@ -730,24 +770,48 @@ batch_uninstall_applications() {
if [[ "$summary_status" == "warn" ]]; then if [[ "$summary_status" == "warn" ]]; then
title="Uninstall incomplete" title="Uninstall incomplete"
fi fi
if is_uninstall_dry_run; then
title="Uninstall dry run complete"
fi
echo "" echo ""
print_summary_block "$title" "${summary_details[@]}" print_summary_block "$title" "${summary_details[@]}"
printf '\n' printf '\n'
if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then # Auto-run brew autoremove if Homebrew casks were uninstalled
# Kick off LaunchServices rebuild in background immediately after summary. if [[ $brew_apps_removed -gt 0 ]]; then
# The caller shows a 3s "Press Enter" prompt, giving the rebuild time to finish if is_uninstall_dry_run; then
# before the user returns to the app list — fixes stale Spotlight entries (#490). log_info "[DRY RUN] Would run brew autoremove"
( else
refresh_launch_services_after_uninstall 2> /dev/null || true # Show spinner while checking for orphaned dependencies
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true if [[ -t 1 ]]; then
) > /dev/null 2>&1 & start_inline_spinner "Checking brew dependencies..."
fi
local autoremove_output removed_count
autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true
removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true)
removed_count=${removed_count:-0}
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ $removed_count -gt 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Cleaned $removed_count orphaned brew dependencies"
echo ""
fi
fi
fi fi
# brew autoremove can be slow — run in background so the prompt returns quickly. # Clean up Dock entries for uninstalled apps.
if [[ $brew_apps_removed -gt 0 ]]; then if [[ $success_count -gt 0 && ${#success_items[@]} -gt 0 ]]; then
(HOMEBREW_NO_ENV_HINTS=1 brew autoremove > /dev/null 2>&1 || true) & if is_uninstall_dry_run; then
log_info "[DRY RUN] Would refresh LaunchServices and update Dock entries"
else
remove_apps_from_dock "${success_items[@]}" 2> /dev/null || true
refresh_launch_services_after_uninstall 2> /dev/null || true
fi
fi fi
_cleanup_sudo_keepalive _cleanup_sudo_keepalive

View File

@@ -168,6 +168,11 @@ brew_uninstall_cask() {
local cask_name="$1" local cask_name="$1"
local app_path="${2:-}" local app_path="${2:-}"
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
debug_log "[DRY RUN] Would brew uninstall --cask --zap $cask_name"
return 0
fi
is_homebrew_available || return 1 is_homebrew_available || return 1
[[ -z "$cask_name" ]] && return 1 [[ -z "$cask_name" ]] && return 1

46
mole
View File

@@ -234,10 +234,16 @@ show_help() {
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization"
printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items"
printf " %s%-28s%s %s\n" "$GREEN" "mo uninstall --dry-run" "$NC" "Preview app uninstall"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --dry-run" "$NC" "Preview project purge"
printf " %s%-28s%s %s\n" "$GREEN" "mo installer --dry-run" "$NC" "Preview installer cleanup"
printf " %s%-28s%s %s\n" "$GREEN" "mo touchid enable --dry-run" "$NC" "Preview Touch ID setup"
printf " %s%-28s%s %s\n" "$GREEN" "mo completion --dry-run" "$NC" "Preview shell completion edits"
printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories"
printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only" printf " %s%-28s%s %s\n" "$GREEN" "mo analyze /Volumes" "$NC" "Analyze external drives only"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version" printf " %s%-28s%s %s\n" "$GREEN" "mo update --force" "$NC" "Force reinstall latest stable version"
printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build" printf " %s%-28s%s %s\n" "$GREEN" "mo update --nightly" "$NC" "Install latest unreleased main branch build"
printf " %s%-28s%s %s\n" "$GREEN" "mo remove --dry-run" "$NC" "Preview Mole removal"
echo echo
printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC"
printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs"
@@ -462,6 +468,8 @@ update_mole() {
# Remove flow (Homebrew + manual + config/cache). # Remove flow (Homebrew + manual + config/cache).
remove_mole() { remove_mole() {
local dry_run_mode="${1:-false}"
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
start_inline_spinner "Detecting Mole installations..." start_inline_spinner "Detecting Mole installations..."
else else
@@ -571,6 +579,31 @@ remove_mole() {
esac esac
local has_error=false local has_error=false
if [[ "$dry_run_mode" == "true" ]]; then
echo ""
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, no files will be removed"
if [[ "$is_homebrew" == "true" ]]; then
echo -e " ${GRAY}${ICON_LIST} Would run: brew uninstall --force mole${NC}"
fi
if [[ ${manual_count:-0} -gt 0 ]]; then
for install in "${manual_installs[@]}"; do
[[ -f "$install" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${install}${NC}"
done
fi
if [[ ${alias_count:-0} -gt 0 ]]; then
for alias in "${alias_installs[@]}"; do
[[ -f "$alias" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: ${alias}${NC}"
done
fi
[[ -d "$HOME/.cache/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.cache/mole${NC}"
[[ -d "$HOME/.config/mole" ]] && echo -e " ${GRAY}${ICON_LIST} Would remove: $HOME/.config/mole${NC}"
printf '\n%s\n\n' "${GREEN}${ICON_SUCCESS}${NC} Dry run complete, no changes made"
exit 0
fi
if [[ "$is_homebrew" == "true" ]]; then if [[ "$is_homebrew" == "true" ]]; then
if [[ -z "$brew_cmd" ]]; then if [[ -z "$brew_cmd" ]]; then
log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH." log_error "Homebrew command not found. Please ensure Homebrew is installed and in your PATH."
@@ -859,7 +892,18 @@ main() {
exit 0 exit 0
;; ;;
"remove") "remove")
remove_mole local dry_run_remove=false
for arg in "${args[@]:1}"; do
case "$arg" in
"--dry-run" | "-n") dry_run_remove=true ;;
*)
echo "Unknown remove option: $arg"
echo "Use 'mole remove [--dry-run]' for supported options."
exit 1
;;
esac
done
remove_mole "$dry_run_remove"
;; ;;
"help" | "--help" | "-h") "help" | "--help" | "-h")
show_help show_help

View File

@@ -1,39 +1,39 @@
#!/usr/bin/env bats #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-cli-home.XXXXXX")"
export HOME export HOME
mkdir -p "$HOME" mkdir -p "$HOME"
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME" export HOME="$ORIGINAL_HOME"
fi fi
} }
create_fake_utils() { create_fake_utils() {
local dir="$1" local dir="$1"
mkdir -p "$dir" mkdir -p "$dir"
cat > "$dir/sudo" <<'SCRIPT' cat >"$dir/sudo" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$1" == "-n" || "$1" == "-v" ]]; then if [[ "$1" == "-n" || "$1" == "-v" ]]; then
exit 0 exit 0
fi fi
exec "$@" exec "$@"
SCRIPT SCRIPT
chmod +x "$dir/sudo" chmod +x "$dir/sudo"
cat > "$dir/bioutil" <<'SCRIPT' cat >"$dir/bioutil" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
if [[ "$1" == "-r" ]]; then if [[ "$1" == "-r" ]]; then
echo "Touch ID: 1" echo "Touch ID: 1"
@@ -41,138 +41,152 @@ if [[ "$1" == "-r" ]]; then
fi fi
exit 0 exit 0
SCRIPT SCRIPT
chmod +x "$dir/bioutil" chmod +x "$dir/bioutil"
} }
setup() { setup() {
rm -rf "$HOME/.config" rm -rf "$HOME/.config"
mkdir -p "$HOME" mkdir -p "$HOME"
} }
@test "mole --help prints command overview" { @test "mole --help prints command overview" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" --help run env HOME="$HOME" "$PROJECT_ROOT/mole" --help
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"mo clean"* ]] [[ "$output" == *"mo clean"* ]]
[[ "$output" == *"mo analyze"* ]] [[ "$output" == *"mo analyze"* ]]
} }
@test "mole --version reports script version" { @test "mole --version reports script version" {
expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')"
run env HOME="$HOME" "$PROJECT_ROOT/mole" --version run env HOME="$HOME" "$PROJECT_ROOT/mole" --version
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"$expected_version"* ]] [[ "$output" == *"$expected_version"* ]]
} }
@test "mole unknown command returns error" { @test "mole unknown command returns error" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command run env HOME="$HOME" "$PROJECT_ROOT/mole" unknown-command
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"Unknown command: unknown-command"* ]] [[ "$output" == *"Unknown command: unknown-command"* ]]
} }
@test "touchid status reports current configuration" { @test "touchid status reports current configuration" {
run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"Touch ID"* ]] [[ "$output" == *"Touch ID"* ]]
} }
@test "mo optimize command is recognized" { @test "mo optimize command is recognized" {
run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'" run bash -c "grep -q '\"optimize\")' '$PROJECT_ROOT/mole'"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "mo analyze binary is valid" { @test "mo analyze binary is valid" {
if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then if [[ -f "$PROJECT_ROOT/bin/analyze-go" ]]; then
[ -x "$PROJECT_ROOT/bin/analyze-go" ] [ -x "$PROJECT_ROOT/bin/analyze-go" ]
run file "$PROJECT_ROOT/bin/analyze-go" run file "$PROJECT_ROOT/bin/analyze-go"
[[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]] [[ "$output" == *"Mach-O"* ]] || [[ "$output" == *"executable"* ]]
else else
skip "analyze-go binary not built" skip "analyze-go binary not built"
fi fi
} }
@test "mo clean --debug creates debug log file" { @test "mo clean --debug creates debug log file" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
MOLE_OUTPUT="$output" MOLE_OUTPUT="$output"
DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log" DEBUG_LOG="$HOME/.config/mole/mole_debug_session.log"
[ -f "$DEBUG_LOG" ] [ -f "$DEBUG_LOG" ]
run grep "Mole Debug Session" "$DEBUG_LOG" run grep "Mole Debug Session" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$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" { @test "mo clean without debug does not show debug log path" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=0 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" != *"Debug session log saved to"* ]] [[ "$output" != *"Debug session log saved to"* ]]
} }
@test "mo clean --debug logs system info" { @test "mo clean --debug logs system info" {
mkdir -p "$HOME/.config/mole" 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 run env HOME="$HOME" TERM="xterm-256color" MOLE_TEST_MODE=1 MO_DEBUG=1 "$PROJECT_ROOT/mole" clean --dry-run
[ "$status" -eq 0 ] [ "$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" run grep "User:" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run grep "Architecture:" "$DEBUG_LOG" run grep "Architecture:" "$DEBUG_LOG"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "touchid status reflects pam file contents" { @test "touchid status reflects pam file contents" {
pam_file="$HOME/pam_test" pam_file="$HOME/pam_test"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"not configured"* ]] [[ "$output" == *"not configured"* ]]
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so auth sufficient pam_tid.so
EOF EOF
run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status run env MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" status
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"enabled"* ]] [[ "$output" == *"enabled"* ]]
} }
@test "enable_touchid inserts pam_tid line in pam file" { @test "enable_touchid inserts pam_tid line in pam file" {
pam_file="$HOME/pam_enable" pam_file="$HOME/pam_enable"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
fake_bin="$HOME/fake-bin" fake_bin="$HOME/fake-bin"
create_fake_utils "$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 run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" enable
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
grep -q "pam_tid.so" "$pam_file" grep -q "pam_tid.so" "$pam_file"
[[ -f "${pam_file}.mole-backup" ]] [[ -f "${pam_file}.mole-backup" ]]
} }
@test "disable_touchid removes pam_tid line" { @test "disable_touchid removes pam_tid line" {
pam_file="$HOME/pam_disable" pam_file="$HOME/pam_disable"
cat > "$pam_file" <<'EOF' cat >"$pam_file" <<'EOF'
auth sufficient pam_tid.so auth sufficient pam_tid.so
auth sufficient pam_opendirectory.so auth sufficient pam_opendirectory.so
EOF EOF
fake_bin="$HOME/fake-bin-disable" fake_bin="$HOME/fake-bin-disable"
create_fake_utils "$fake_bin" create_fake_utils "$fake_bin"
run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable run env PATH="$fake_bin:$PATH" MOLE_PAM_SUDO_FILE="$pam_file" "$PROJECT_ROOT/bin/touchid.sh" disable
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run grep "pam_tid.so" "$pam_file" run grep "pam_tid.so" "$pam_file"
[ "$status" -ne 0 ] [ "$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 #!/usr/bin/env bats
setup_file() { setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}" ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME export ORIGINAL_HOME
ORIGINAL_PATH="${PATH:-}" ORIGINAL_PATH="${PATH:-}"
export ORIGINAL_PATH export ORIGINAL_PATH
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")" HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-completion-home.XXXXXX")"
export HOME export HOME
mkdir -p "$HOME" mkdir -p "$HOME"
PATH="$PROJECT_ROOT:$PATH" PATH="$PROJECT_ROOT:$PATH"
export PATH export PATH
} }
teardown_file() { teardown_file() {
rm -rf "$HOME" rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME" export HOME="$ORIGINAL_HOME"
fi fi
if [[ -n "${ORIGINAL_PATH:-}" ]]; then if [[ -n "${ORIGINAL_PATH:-}" ]]; then
export PATH="$ORIGINAL_PATH" export PATH="$ORIGINAL_PATH"
fi fi
} }
setup() { setup() {
rm -rf "$HOME/.config" rm -rf "$HOME/.config"
rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" rm -rf "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"
mkdir -p "$HOME" mkdir -p "$HOME"
} }
@test "completion script exists and is executable" { @test "completion script exists and is executable" {
[ -f "$PROJECT_ROOT/bin/completion.sh" ] [ -f "$PROJECT_ROOT/bin/completion.sh" ]
[ -x "$PROJECT_ROOT/bin/completion.sh" ] [ -x "$PROJECT_ROOT/bin/completion.sh" ]
} }
@test "completion script has valid bash syntax" { @test "completion script has valid bash syntax" {
run bash -n "$PROJECT_ROOT/bin/completion.sh" run bash -n "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "completion --help shows usage" { @test "completion --help shows usage" {
run "$PROJECT_ROOT/bin/completion.sh" --help run "$PROJECT_ROOT/bin/completion.sh" --help
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" == *"Usage: mole completion"* ]] [[ "$output" == *"Usage: mole completion"* ]]
[[ "$output" == *"Auto-install"* ]] [[ "$output" == *"Auto-install"* ]]
} }
@test "completion bash generates valid bash script" { @test "completion bash generates valid bash script" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]] [[ "$output" == *"_mole_completions"* ]]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]] [[ "$output" == *"complete -F _mole_completions mole mo"* ]]
} }
@test "completion bash script includes all commands" { @test "completion bash script includes all commands" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"optimize"* ]] [[ "$output" == *"optimize"* ]]
[[ "$output" == *"clean"* ]] [[ "$output" == *"clean"* ]]
[[ "$output" == *"uninstall"* ]] [[ "$output" == *"uninstall"* ]]
[[ "$output" == *"analyze"* ]] [[ "$output" == *"analyze"* ]]
[[ "$output" == *"status"* ]] [[ "$output" == *"status"* ]]
[[ "$output" == *"purge"* ]] [[ "$output" == *"purge"* ]]
[[ "$output" == *"touchid"* ]] [[ "$output" == *"touchid"* ]]
[[ "$output" == *"completion"* ]] [[ "$output" == *"completion"* ]]
} }
@test "completion bash script supports mo command" { @test "completion bash script supports mo command" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"complete -F _mole_completions mole mo"* ]] [[ "$output" == *"complete -F _mole_completions mole mo"* ]]
} }
@test "completion bash can be loaded in bash" { @test "completion bash can be loaded in bash" {
run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole" run bash -c "eval \"\$(\"$PROJECT_ROOT/bin/completion.sh\" bash)\" && complete -p mole"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"_mole_completions"* ]] [[ "$output" == *"_mole_completions"* ]]
} }
@test "completion zsh generates valid zsh script" { @test "completion zsh generates valid zsh script" {
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"#compdef mole mo"* ]] [[ "$output" == *"#compdef mole mo"* ]]
[[ "$output" == *"_mole()"* ]] [[ "$output" == *"_mole()"* ]]
} }
@test "completion zsh includes command descriptions" { @test "completion zsh includes command descriptions" {
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"optimize:Check and maintain system"* ]] [[ "$output" == *"optimize:Check and maintain system"* ]]
[[ "$output" == *"clean:Free up disk space"* ]] [[ "$output" == *"clean:Free up disk space"* ]]
} }
@test "completion fish generates valid fish script" { @test "completion fish generates valid fish script" {
run "$PROJECT_ROOT/bin/completion.sh" fish run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"complete -c mole"* ]] [[ "$output" == *"complete -c mole"* ]]
[[ "$output" == *"complete -c mo"* ]] [[ "$output" == *"complete -c mo"* ]]
} }
@test "completion fish includes both mole and mo commands" { @test "completion fish includes both mole and mo commands" {
output="$("$PROJECT_ROOT/bin/completion.sh" fish)" output="$("$PROJECT_ROOT/bin/completion.sh" fish)"
mole_count=$(echo "$output" | grep -c "complete -c mole") mole_count=$(echo "$output" | grep -c "complete -c mole")
mo_count=$(echo "$output" | grep -c "complete -c mo") mo_count=$(echo "$output" | grep -c "complete -c mo")
[ "$mole_count" -gt 0 ] [ "$mole_count" -gt 0 ]
[ "$mo_count" -gt 0 ] [ "$mo_count" -gt 0 ]
} }
@test "completion auto-install detects zsh" { @test "completion auto-install detects zsh" {
# shellcheck disable=SC2030,SC2031 # shellcheck disable=SC2030,SC2031
export SHELL=/bin/zsh export SHELL=/bin/zsh
# Simulate auto-install (no interaction) # Simulate auto-install (no interaction)
run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\"" run bash -c "echo 'y' | \"$PROJECT_ROOT/bin/completion.sh\""
if [[ "$output" == *"Already configured"* ]]; then if [[ "$output" == *"Already configured"* ]]; then
skip "Already configured from previous test" skip "Already configured from previous test"
fi 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" run grep -E "mole[[:space:]]+completion" "$HOME/.zshrc"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }
@test "completion auto-install detects already installed" { @test "completion auto-install detects already installed" {
# shellcheck disable=SC2031 mkdir -p "$HOME"
export SHELL=/bin/zsh # shellcheck disable=SC2016
mkdir -p "$HOME" echo 'eval "$(mole completion zsh)"' >"$HOME/.zshrc"
# shellcheck disable=SC2016
echo 'eval "$(mole completion zsh)"' > "$HOME/.zshrc"
run "$PROJECT_ROOT/bin/completion.sh" run env SHELL=/bin/zsh "$PROJECT_ROOT/bin/completion.sh"
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
[[ "$output" == *"updated"* ]] [[ "$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" { @test "completion script handles invalid shell argument" {
run "$PROJECT_ROOT/bin/completion.sh" invalid-shell run "$PROJECT_ROOT/bin/completion.sh" invalid-shell
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
} }
@test "completion subcommand supports bash/zsh/fish" { @test "completion subcommand supports bash/zsh/fish" {
run "$PROJECT_ROOT/bin/completion.sh" bash run "$PROJECT_ROOT/bin/completion.sh" bash
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" zsh run "$PROJECT_ROOT/bin/completion.sh" zsh
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
run "$PROJECT_ROOT/bin/completion.sh" fish run "$PROJECT_ROOT/bin/completion.sh" fish
[ "$status" -eq 0 ] [ "$status" -eq 0 ]
} }

View File

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

View File

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

View File

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