1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 15:04:42 +00:00

feat: implement installer cleanup functionality, add ZIP and file descriptor installer tests, and update README

This commit is contained in:
Tw93
2026-01-04 15:46:48 +08:00
parent ad42266b09
commit 9f504dc249
6 changed files with 1048 additions and 486 deletions

View File

@@ -104,11 +104,11 @@ $ mo uninstall
Select Apps to Remove Select Apps to Remove
═══════════════════════════ ═══════════════════════════
▶ ☑ Adobe Creative Cloud (9.4G) | Old ▶ ☑ Photoshop 2024 (4.2G) | Old
WeChat (2.1G) | Recent IntelliJ IDEA (2.8G) | Recent
Final Cut Pro (3.8G) | Recent Premiere Pro (3.4G) | Recent
Uninstalling: Adobe Creative Cloud Uninstalling: Photoshop 2024
✓ Removed application ✓ Removed application
✓ Cleaned 52 related files across 12 locations ✓ Cleaned 52 related files across 12 locations
@@ -155,7 +155,7 @@ Analyze Disk ~/Documents | Total: 156.8GB
4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB 4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB
5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB 5. ██░░░░░░░░░░░░░░░░░ 5.2% | 📄 backup_2023.zip 8.2GB
↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large(24) | Q Quit ↑↓←→ Navigate | O Open | F Show | ⌫ Delete | L Large files | Q Quit
``` ```
### Live System Status ### Live System Status
@@ -223,6 +223,23 @@ When custom paths are configured, only those directories are scanned. Otherwise,
</details> </details>
### Installer Cleanup
Find and remove large installer files scattered across Downloads, Desktop, Homebrew caches, iCloud, and Mail. Each file is labeled by source to help you know where the space is hiding.
```bash
mo installer
Select Installers to Remove - 3.8GB (5 selected)
➤ ● Photoshop_2024.dmg 1.2GB | Downloads
● IntelliJ_IDEA.dmg 850.6MB | Downloads
● Illustrator_Setup.pkg 920.4MB | Downloads
● PyCharm_Pro.dmg 640.5MB | Homebrew
● Acrobat_Reader.dmg 220.4MB | Downloads
○ AppCode_Legacy.zip 410.6MB | Downloads
```
## Quick Launchers ## Quick Launchers
Launch Mole commands instantly from Raycast or Alfred: Launch Mole commands instantly from Raycast or Alfred:

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Mole - Installer command # Mole - Installer command
# Find and remove installer files (.dmg, .pkg, .mpkg, .iso, .xip, .zip) # Find and remove installer files - .dmg, .pkg, .mpkg, .iso, .xip, .zip
set -euo pipefail set -euo pipefail
@@ -33,18 +33,32 @@ readonly INSTALLER_SCAN_PATHS=(
"$HOME/Public" "$HOME/Public"
"$HOME/Library/Downloads" "$HOME/Library/Downloads"
"/Users/Shared" "/Users/Shared"
"/Users/Shared/Downloads" # Search one level deeper "/Users/Shared/Downloads"
"$HOME/Library/Caches/Homebrew"
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"
"$HOME/Library/Containers/com.apple.mail/Data/Library/Mail Downloads"
"$HOME/Library/Application Support/Telegram Desktop"
"$HOME/Downloads/Telegram Desktop"
) )
readonly MAX_ZIP_ENTRIES=5 readonly MAX_ZIP_ENTRIES=5
ZIP_LIST_CMD=()
# Check for installer payloads inside ZIP (single pass, fused size and pattern check) if command -v zipinfo > /dev/null 2>&1; then
ZIP_LIST_CMD=(zipinfo -1)
elif command -v unzip > /dev/null 2>&1; then
ZIP_LIST_CMD=(unzip -Z -1)
fi
TERMINAL_WIDTH=0
# Check for installer payloads inside ZIP - single pass, fused size and pattern check
is_installer_zip() { is_installer_zip() {
local zip="$1" local zip="$1"
local cap="$MAX_ZIP_ENTRIES" local cap="$MAX_ZIP_ENTRIES"
zipinfo -1 "$zip" >/dev/null 2>&1 || return 1 [[ ${#ZIP_LIST_CMD[@]} -gt 0 ]] || return 1
zipinfo -1 "$zip" 2>/dev/null \ if ! "${ZIP_LIST_CMD[@]}" "$zip" 2>/dev/null \
| head -n $((cap + 1)) \ | head -n $((cap + 1)) \
| awk -v cap="$cap" ' | awk -v cap="$cap" '
/\.(app|pkg|dmg|xip)(\/|$)/ { found=1 } /\.(app|pkg|dmg|xip)(\/|$)/ { found=1 }
@@ -52,7 +66,28 @@ is_installer_zip() {
if (NR > cap) exit 1 if (NR > cap) exit 1
exit found ? 0 : 1 exit found ? 0 : 1
} }
' '; then
return 1
fi
return 0
}
handle_candidate_file() {
local file="$1"
[[ -L "$file" ]] && return 0 # Skip symlinks explicitly
case "$file" in
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
echo "$file"
;;
*.zip)
[[ -r "$file" ]] || return 0
if is_installer_zip "$file" 2>/dev/null; then
echo "$file"
fi
;;
esac
} }
scan_installers_in_path() { scan_installers_in_path() {
@@ -65,18 +100,7 @@ scan_installers_in_path() {
if command -v fd > /dev/null 2>&1; then if command -v fd > /dev/null 2>&1; then
while IFS= read -r file; do while IFS= read -r file; do
[[ -L "$file" ]] && continue # Skip symlinks explicitly handle_candidate_file "$file"
case "$file" in
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
echo "$file"
;;
*.zip)
[[ -r "$file" ]] || continue
if is_installer_zip "$file" 2>/dev/null; then
echo "$file"
fi
;;
esac
done < <( done < <(
fd --no-ignore --hidden --type f --max-depth "$max_depth" \ fd --no-ignore --hidden --type f --max-depth "$max_depth" \
-e dmg -e pkg -e mpkg -e iso -e xip -e zip \ -e dmg -e pkg -e mpkg -e iso -e xip -e zip \
@@ -84,18 +108,7 @@ scan_installers_in_path() {
) )
else else
while IFS= read -r file; do while IFS= read -r file; do
[[ -L "$file" ]] && continue # Skip symlinks explicitly handle_candidate_file "$file"
case "$file" in
*.dmg|*.pkg|*.mpkg|*.iso|*.xip)
echo "$file"
;;
*.zip)
[[ -r "$file" ]] || continue
if is_installer_zip "$file" 2>/dev/null; then
echo "$file"
fi
;;
esac
done < <( done < <(
find "$path" -maxdepth "$max_depth" -type f \ find "$path" -maxdepth "$max_depth" -type f \
\( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \ \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \
@@ -118,33 +131,100 @@ declare -i total_size_freed_kb=0
# Global arrays for installer data # Global arrays for installer data
declare -a INSTALLER_PATHS=() declare -a INSTALLER_PATHS=()
declare -a INSTALLER_SIZES=() declare -a INSTALLER_SIZES=()
declare -a INSTALLER_SOURCES=()
declare -a DISPLAY_NAMES=() declare -a DISPLAY_NAMES=()
# Get source directory display name - for example "Downloads" or "Desktop"
get_source_display() {
local file_path="$1"
local dir_path="${file_path%/*}"
# Match against known paths and return friendly names
case "$dir_path" in
"$HOME/Downloads"*) echo "Downloads" ;;
"$HOME/Desktop"*) echo "Desktop" ;;
"$HOME/Documents"*) echo "Documents" ;;
"$HOME/Public"*) echo "Public" ;;
"$HOME/Library/Downloads"*) echo "Library" ;;
"/Users/Shared"*) echo "Shared" ;;
"$HOME/Library/Caches/Homebrew"*) echo "Homebrew" ;;
"$HOME/Library/Mobile Documents/com~apple~CloudDocs/Downloads"*) echo "iCloud" ;;
"$HOME/Library/Containers/com.apple.mail"*) echo "Mail" ;;
*"Telegram Desktop"*) echo "Telegram" ;;
*) echo "${dir_path##*/}" ;;
esac
}
get_terminal_width() {
if [[ $TERMINAL_WIDTH -le 0 ]]; then
TERMINAL_WIDTH=$(tput cols 2>/dev/null || echo 80)
fi
echo "$TERMINAL_WIDTH"
}
# Format installer display with alignment - similar to purge command
format_installer_display() {
local filename="$1"
local size_str="$2"
local source="$3"
# Terminal width for alignment
local terminal_width
terminal_width=$(get_terminal_width)
local fixed_width=24 # Reserve for size and source
local available_width=$((terminal_width - fixed_width))
# Bounds check: 20-40 chars for filename
[[ $available_width -lt 20 ]] && available_width=20
[[ $available_width -gt 40 ]] && available_width=40
# Truncate filename if needed
local truncated_name
truncated_name=$(truncate_by_display_width "$filename" "$available_width")
local current_width
current_width=$(get_display_width "$truncated_name")
local char_count=${#truncated_name}
local padding=$((available_width - current_width))
local printf_width=$((char_count + padding))
# Format: "filename size | source"
printf "%-*s %8s | %-10s" "$printf_width" "$truncated_name" "$size_str" "$source"
}
# Collect all installers with their metadata # Collect all installers with their metadata
collect_installers() { collect_installers() {
printf '\n'
echo -e "${BLUE}━━━ Scanning for installers ━━━${NC}"
# Clear previous results # Clear previous results
INSTALLER_PATHS=() INSTALLER_PATHS=()
INSTALLER_SIZES=() INSTALLER_SIZES=()
INSTALLER_SOURCES=()
DISPLAY_NAMES=() DISPLAY_NAMES=()
# Start scanning with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Scanning for installers..."
fi
# Scan all paths, deduplicate, and sort results # Scan all paths, deduplicate, and sort results
local -a all_files=() local -a all_files=()
local sorted_paths
sorted_paths=$(scan_all_installers | sort -u)
if [[ -z "$sorted_paths" ]]; then
echo -e " ${YELLOW}No installer files found${NC}"
return 1
fi
# Read sorted results into array
while IFS= read -r file; do while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
all_files+=("$file") all_files+=("$file")
done <<< "$sorted_paths" done < <(scan_all_installers | sort -u)
if [[ -t 1 ]]; then
stop_inline_spinner
fi
if [[ ${#all_files[@]} -eq 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} Great! No installer files to clean"
return 1
fi
# Calculate sizes with spinner
if [[ -t 1 ]]; then
start_inline_spinner "Calculating sizes..."
fi
# Process each installer # Process each installer
for file in "${all_files[@]}"; do for file in "${all_files[@]}"; do
@@ -154,16 +234,253 @@ collect_installers() {
file_size=$(get_file_size "$file") file_size=$(get_file_size "$file")
fi fi
# Store installer path and size in parallel arrays # Get source directory
local source
source=$(get_source_display "$file")
# Format human readable size
local size_human
size_human=$(bytes_to_human "$file_size")
# Format display with alignment
local display
display=$(format_installer_display "$(basename "$file")" "$size_human" "$source")
# Store installer data in parallel arrays
INSTALLER_PATHS+=("$file") INSTALLER_PATHS+=("$file")
INSTALLER_SIZES+=("$file_size") INSTALLER_SIZES+=("$file_size")
DISPLAY_NAMES+=("$(basename "$file")") INSTALLER_SOURCES+=("$source")
DISPLAY_NAMES+=("$display")
done done
echo -e " ${GREEN}Found ${#INSTALLER_PATHS[@]} installer(s)${NC}" if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0 return 0
} }
# Installer selector with Select All / Invert support
select_installers() {
local -a items=("$@")
local total_items=${#items[@]}
local clear_line=$'\r\033[2K'
if [[ $total_items -eq 0 ]]; then
return 1
fi
# Calculate items per page based on terminal height
_get_items_per_page() {
local term_height=24
if [[ -t 0 ]] || [[ -t 2 ]]; then
term_height=$(stty size </dev/tty 2>/dev/null | awk '{print $1}')
fi
if [[ -z "$term_height" || $term_height -le 0 ]]; then
if command -v tput > /dev/null 2>&1; then
term_height=$(tput lines 2>/dev/null || echo "24")
else
term_height=24
fi
fi
local reserved=6
local available=$((term_height - reserved))
if [[ $available -lt 3 ]]; then
echo 3
elif [[ $available -gt 50 ]]; then
echo 50
else
echo "$available"
fi
}
local items_per_page=$(_get_items_per_page)
local cursor_pos=0
local top_index=0
# Initialize selection (all unselected by default)
local -a selected=()
for ((i = 0; i < total_items; i++)); do
selected[i]=false
done
local original_stty=""
if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
original_stty=$(stty -g 2>/dev/null || echo "")
fi
restore_terminal() {
trap - EXIT INT TERM
show_cursor
if [[ -n "${original_stty:-}" ]]; then
stty "${original_stty}" 2>/dev/null || stty sane 2>/dev/null || true
fi
}
handle_interrupt() {
restore_terminal
exit 130
}
draw_menu() {
items_per_page=$(_get_items_per_page)
local max_top_index=0
if [[ $total_items -gt $items_per_page ]]; then
max_top_index=$((total_items - items_per_page))
fi
if [[ $top_index -gt $max_top_index ]]; then
top_index=$max_top_index
fi
if [[ $top_index -lt 0 ]]; then
top_index=0
fi
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -gt $((visible_count - 1)) ]]; then
cursor_pos=$((visible_count - 1))
fi
if [[ $cursor_pos -lt 0 ]]; then
cursor_pos=0
fi
printf "\033[H"
# Calculate selected size and count
local selected_size=0
local selected_count=0
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected_size=$((selected_size + ${INSTALLER_SIZES[i]:-0}))
((selected_count++))
fi
done
local selected_human
selected_human=$(bytes_to_human "$selected_size")
# Show position indicator if scrolling is needed
local scroll_indicator=""
if [[ $total_items -gt $items_per_page ]]; then
local current_pos=$((top_index + cursor_pos + 1))
scroll_indicator=" ${GRAY}[${current_pos}/${total_items}]${NC}"
fi
printf "${PURPLE_BOLD}Select Installers to Remove${NC}%s ${GRAY}- ${selected_human} ($selected_count selected)${NC}\n" "$scroll_indicator"
printf "%s\n" "$clear_line"
# Calculate visible range
local end_index=$((top_index + visible_count))
# Draw only visible items
for ((i = top_index; i < end_index; i++)); do
local checkbox="$ICON_EMPTY"
[[ ${selected[i]} == true ]] && checkbox="$ICON_SOLID"
local rel_pos=$((i - top_index))
if [[ $rel_pos -eq $cursor_pos ]]; then
printf "%s${CYAN}${ICON_ARROW} %s %s${NC}\n" "$clear_line" "$checkbox" "${items[i]}"
else
printf "%s %s %s\n" "$clear_line" "$checkbox" "${items[i]}"
fi
done
# Fill empty slots
local items_shown=$visible_count
for ((i = items_shown; i < items_per_page; i++)); do
printf "%s\n" "$clear_line"
done
printf "%s\n" "$clear_line"
printf "%s${GRAY}${ICON_NAV_UP}${ICON_NAV_DOWN} | Space Select | Enter Confirm | A All | I Invert | Q Quit${NC}\n" "$clear_line"
}
trap restore_terminal EXIT
trap handle_interrupt INT TERM
stty -echo -icanon intr ^C 2>/dev/null || true
hide_cursor
if [[ -t 1 ]]; then
printf "\033[2J\033[H" >&2
fi
# Main loop
while true; do
draw_menu
IFS= read -r -s -n1 key || key=""
case "$key" in
$'\x1b')
IFS= read -r -s -n1 -t 1 key2 || key2=""
if [[ "$key2" == "[" ]]; then
IFS= read -r -s -n1 -t 1 key3 || key3=""
case "$key3" in
A) # Up arrow
if [[ $cursor_pos -gt 0 ]]; then
((cursor_pos--))
elif [[ $top_index -gt 0 ]]; then
((top_index--))
fi
;;
B) # Down arrow
local absolute_index=$((top_index + cursor_pos))
local last_index=$((total_items - 1))
if [[ $absolute_index -lt $last_index ]]; then
local visible_count=$((total_items - top_index))
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
((cursor_pos++))
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
((top_index++))
fi
fi
;;
esac
else
# ESC alone
restore_terminal
return 1
fi
;;
" ") # Space - toggle current item
local idx=$((top_index + cursor_pos))
if [[ ${selected[idx]} == true ]]; then
selected[idx]=false
else
selected[idx]=true
fi
;;
"a"|"A") # Select all
for ((i = 0; i < total_items; i++)); do
selected[i]=true
done
;;
"i"|"I") # Invert selection
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
selected[i]=false
else
selected[i]=true
fi
done
;;
"q"|"Q"|$'\x03') # Quit or Ctrl-C
restore_terminal
return 1
;;
""|$'\n'|$'\r') # Enter - confirm
MOLE_SELECTION_RESULT=""
for ((i = 0; i < total_items; i++)); do
if [[ ${selected[i]} == true ]]; then
[[ -n "$MOLE_SELECTION_RESULT" ]] && MOLE_SELECTION_RESULT+=","
MOLE_SELECTION_RESULT+="$i"
fi
done
restore_terminal
return 0
;;
esac
done
}
# Show menu for user selection # Show menu for user selection
show_installer_menu() { show_installer_menu() {
if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then
@@ -172,14 +489,8 @@ show_installer_menu() {
echo "" echo ""
local title="Select installers to remove"
MOLE_SELECTION_RESULT="" MOLE_SELECTION_RESULT=""
paginated_multi_select "$title" "${DISPLAY_NAMES[@]}" if ! select_installers "${DISPLAY_NAMES[@]}"; then
local selection_exit=$?
if [[ $selection_exit -ne 0 ]]; then
echo ""
echo -e "${YELLOW}Cancelled${NC}"
return 1 return 1
fi fi
@@ -196,12 +507,54 @@ delete_selected_installers() {
return 1 return 1
fi fi
printf '\n' # Calculate total size for confirmation
echo -e "${BLUE}━━━ Removing installers ━━━${NC}" local confirm_size=0
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_SIZES[@]} ]]; then
confirm_size=$((confirm_size + ${INSTALLER_SIZES[$idx]:-0}))
fi
done
local confirm_human
confirm_human=$(bytes_to_human "$confirm_size")
# Delete each selected installer # Show files to be deleted
echo -e "${PURPLE_BOLD}Files to be removed:${NC}"
for idx in "${selected_indices[@]}"; do
if [[ "$idx" =~ ^[0-9]+$ ]] && [[ $idx -lt ${#INSTALLER_PATHS[@]} ]]; then
local file_path="${INSTALLER_PATHS[$idx]}"
local file_size="${INSTALLER_SIZES[$idx]}"
local size_human
size_human=$(bytes_to_human "$file_size")
echo -e " ${GREEN}${ICON_SUCCESS}${NC} $(basename "$file_path") ${GRAY}(${size_human})${NC}"
fi
done
# Confirm deletion
echo ""
echo -ne "${PURPLE}${ICON_ARROW}${NC} Delete ${#selected_indices[@]} installer(s) (${confirm_human}) ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
IFS= read -r -s -n1 confirm || confirm=""
case "$confirm" in
$'\e'|q|Q)
return 1
;;
""|$'\n'|$'\r')
printf "\r\033[K" # Clear prompt line
echo "" # Single line break
;;
*)
return 1
;;
esac
# Delete each selected installer with spinner
total_deleted=0 total_deleted=0
total_size_freed_kb=0 total_size_freed_kb=0
if [[ -t 1 ]]; then
start_inline_spinner "Removing installers..."
fi
for idx in "${selected_indices[@]}"; do for idx in "${selected_indices[@]}"; do
if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then
continue continue
@@ -212,58 +565,68 @@ delete_selected_installers() {
# Validate path before deletion # Validate path before deletion
if ! validate_path_for_deletion "$file_path"; then if ! validate_path_for_deletion "$file_path"; then
echo -e " ${RED}${ICON_FAILED}${NC} Cannot delete (invalid path): $(basename "$file_path")"
continue continue
fi fi
# Delete the file # Delete the file
if safe_remove "$file_path" true; then if safe_remove "$file_path" true; then
local human_size
human_size=$(bytes_to_human "$file_size")
total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024))) total_size_freed_kb=$((total_size_freed_kb + ((file_size + 1023) / 1024)))
echo -e " ${GREEN}${ICON_SUCCESS}${NC} Deleted: $(basename "$file_path") ${GRAY}($human_size)${NC}"
total_deleted=$((total_deleted + 1)) total_deleted=$((total_deleted + 1))
else
echo -e " ${RED}${ICON_FAILED}${NC} Failed to delete: $(basename "$file_path")"
fi fi
done done
if [[ -t 1 ]]; then
stop_inline_spinner
fi
return 0 return 0
} }
# Perform the installers cleanup # Perform the installers cleanup
perform_installers() { perform_installers() {
# Enter alt screen for scanning and selection
if [[ -t 1 ]]; then
enter_alt_screen
printf "\033[2J\033[H" >&2
fi
# Collect installers # Collect installers
if ! collect_installers; then if ! collect_installers; then
if [[ -t 1 ]]; then leave_alt_screen; fi
return 2 # Nothing to clean return 2 # Nothing to clean
fi fi
# Show menu # Show menu
if ! show_installer_menu; then if ! show_installer_menu; then
if [[ -t 1 ]]; then leave_alt_screen; fi
return 1 # User cancelled return 1 # User cancelled
fi fi
# Leave alt screen before deletion (so confirmation and results are on main screen)
if [[ -t 1 ]]; then
leave_alt_screen
fi
# Delete selected # Delete selected
delete_selected_installers if ! delete_selected_installers; then
return 1
fi
return 0 return 0
} }
show_summary() { show_summary() {
echo "" local summary_heading="Installers cleaned"
local summary_heading="Cleanup complete"
local -a summary_details=() local -a summary_details=()
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+=("Installers removed: $total_deleted") summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}")
summary_details+=("Space freed: ${GREEN}${freed_mb}MB${NC}") summary_details+=("Your Mac is cleaner now!")
summary_details+=("Free space now: $(get_free_space)")
else else
summary_details+=("No installers were removed") summary_details+=("No installers were removed")
summary_details+=("Free space now: $(get_free_space)")
fi fi
print_summary_block "$summary_heading" "${summary_details[@]}" print_summary_block "$summary_heading" "${summary_details[@]}"
@@ -284,13 +647,6 @@ main() {
esac esac
done done
# Clear screen for better UX
if [[ -t 1 ]]; then
printf '\033[2J\033[H'
fi
printf '\n'
echo -e "${PURPLE_BOLD}Clean Installer Files${NC}"
hide_cursor hide_cursor
perform_installers perform_installers
local exit_code=$? local exit_code=$?
@@ -304,9 +660,7 @@ main() {
printf '\n' printf '\n'
;; ;;
2) 2)
printf '\n' # Already handled by collect_installers
echo -e "${YELLOW}No installer files found in default locations${NC}"
printf '\n'
;; ;;
esac esac

View File

@@ -56,7 +56,43 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then
export TERM="xterm-256color" export TERM="xterm-256color"
fi fi
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
set -- tests fd_available=0
zip_available=0
zip_list_available=0
if command -v fd > /dev/null 2>&1; then
fd_available=1
fi
if command -v zip > /dev/null 2>&1; then
zip_available=1
fi
if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then
zip_list_available=1
fi
TEST_FILES=()
while IFS= read -r file; do
case "$file" in
tests/installer_fd.bats)
if [[ $fd_available -eq 1 ]]; then
TEST_FILES+=("$file")
fi
;;
tests/installer_zip.bats)
if [[ $zip_available -eq 1 && $zip_list_available -eq 1 ]]; then
TEST_FILES+=("$file")
fi
;;
*)
TEST_FILES+=("$file")
;;
esac
done < <(find tests -type f -name '*.bats' | sort)
if [[ ${#TEST_FILES[@]} -gt 0 ]]; then
set -- "${TEST_FILES[@]}"
else
set -- tests
fi
fi fi
use_color=false use_color=false
if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then

View File

@@ -47,187 +47,6 @@ setup() {
} }
# Test scan_installers_in_path function directly # Test scan_installers_in_path function directly
# Tests are duplicated to cover both fd and find code paths
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Tests using fd (when available)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@test "scan_installers_in_path (fd): finds .dmg files" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/Chrome.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"Chrome.dmg"* ]]
}
@test "scan_installers_in_path (fd): finds multiple installer types" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/App1.dmg"
touch "$HOME/Downloads/App2.pkg"
touch "$HOME/Downloads/App3.iso"
touch "$HOME/Downloads/App.mpkg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App1.dmg"* ]]
[[ "$output" == *"App2.pkg"* ]]
[[ "$output" == *"App3.iso"* ]]
[[ "$output" == *"App.mpkg"* ]]
}
@test "scan_installers_in_path (fd): respects max depth" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
mkdir -p "$HOME/Downloads/level1/level2/level3"
touch "$HOME/Downloads/shallow.dmg"
touch "$HOME/Downloads/level1/mid.dmg"
touch "$HOME/Downloads/level1/level2/deep.dmg"
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
# Default max depth is 2
[[ "$output" == *"shallow.dmg"* ]]
[[ "$output" == *"mid.dmg"* ]]
[[ "$output" == *"deep.dmg"* ]]
[[ "$output" != *"too-deep.dmg"* ]]
}
@test "scan_installers_in_path (fd): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
mkdir -p "$HOME/Downloads/level1"
touch "$HOME/Downloads/top.dmg"
touch "$HOME/Downloads/level1/nested.dmg"
run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"top.dmg"* ]]
[[ "$output" != *"nested.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles non-existent directory" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
@test "scan_installers_in_path (fd): ignores non-installer files" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.jpg"
touch "$HOME/Downloads/archive.tar.gz"
touch "$HOME/Downloads/Installer.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" != *"document.pdf"* ]]
[[ "$output" != *"image.jpg"* ]]
[[ "$output" != *"archive.tar.gz"* ]]
[[ "$output" == *"Installer.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles filenames with spaces" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/My App Installer.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"My App Installer.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles filenames with special characters" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
}
@test "scan_installers_in_path (fd): returns empty for directory with no installers" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
# Create some non-installer files
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.png"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Tests using find (forced fallback by hiding fd) # Tests using find (forced fallback by hiding fd)
@@ -332,126 +151,6 @@ setup() {
[[ "$output" == *"Installer.dmg"* ]] [[ "$output" == *"Installer.dmg"* ]]
} }
# Test ZIP installer detection
@test "is_installer_zip: rejects ZIP with installer content but too many entries" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a ZIP with too many files (exceeds MAX_ZIP_ENTRIES=5)
# Include a .app file to have installer content
mkdir -p "$HOME/Downloads/large-app"
touch "$HOME/Downloads/large-app/MyApp.app"
for i in {1..9}; do
touch "$HOME/Downloads/large-app/file$i.txt"
done
(cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "NOT_INSTALLER" ]]
}
@test "is_installer_zip: detects ZIP with app content" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "INSTALLER" ]]
}
@test "is_installer_zip: rejects ZIP with only regular files" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
mkdir -p "$HOME/Downloads/data"
touch "$HOME/Downloads/data/file1.txt"
touch "$HOME/Downloads/data/file2.pdf"
(cd "$HOME/Downloads" && zip -q -r data.zip data)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "NOT_INSTALLER" ]]
}
# Integration tests: ZIP scanning inside scan_all_installers
@test "scan_all_installers: finds installer ZIP in Downloads" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a valid installer ZIP (contains .app)
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r installer.zip app-content)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"installer.zip"* ]]
}
@test "scan_all_installers: ignores non-installer ZIP in Downloads" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a non-installer ZIP (only regular files)
mkdir -p "$HOME/Downloads/data"
touch "$HOME/Downloads/data/file1.txt"
touch "$HOME/Downloads/data/file2.pdf"
(cd "$HOME/Downloads" && zip -q -r data.zip data)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"data.zip"* ]]
}
@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
@@ -520,109 +219,8 @@ setup() {
[[ -z "$output" ]] [[ -z "$output" ]]
} }
# Failure path tests for scan_installers_in_path
@test "scan_installers_in_path: skips corrupt ZIP files" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a corrupt ZIP file by just writing garbage data
echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should succeed (return 0) and silently skip the corrupt ZIP
[ "$status" -eq 0 ]
# Output should be empty since corrupt.zip is not a valid installer
[[ -z "$output" ]]
}
@test "scan_installers_in_path: handles permission-denied files" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a valid installer ZIP
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r readable.zip app-content)
# Create a readable installer ZIP alongside a permission-denied file
mkdir -p "$HOME/Downloads/restricted-app"
touch "$HOME/Downloads/restricted-app/App.app"
(cd "$HOME/Downloads" && zip -q -r restricted.zip restricted-app)
# Remove read permissions from restricted.zip
chmod 000 "$HOME/Downloads/restricted.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should succeed and find the readable.zip but skip restricted.zip
[ "$status" -eq 0 ]
[[ "$output" == *"readable.zip"* ]]
[[ "$output" != *"restricted.zip"* ]]
# Cleanup: restore permissions for teardown
chmod 644 "$HOME/Downloads/restricted.zip"
}
@test "scan_installers_in_path: finds installer ZIP alongside corrupt ZIPs" {
if ! command -v zipinfo > /dev/null 2>&1; then
skip "zipinfo not available"
fi
# Create a valid installer ZIP
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r valid-installer.zip app-content)
# Create a corrupt ZIP
echo "garbage data" > "$HOME/Downloads/corrupt.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should find the valid ZIP and silently skip the corrupt one
[ "$status" -eq 0 ]
[[ "$output" == *"valid-installer.zip"* ]]
[[ "$output" != *"corrupt.zip"* ]]
}
# Symlink handling tests # Symlink handling tests
@test "scan_installers_in_path (fd): skips symlinks to regular files" {
if ! command -v fd > /dev/null 2>&1; then
skip "fd not available on this system"
fi
touch "$HOME/Downloads/real.dmg"
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"real.dmg"* ]]
[[ "$output" != *"symlink.dmg"* ]]
[[ "$output" != *"dangling.lnk"* ]]
}
@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"

245
tests/installer_fd.bats Normal file
View File

@@ -0,0 +1,245 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
if command -v fd > /dev/null 2>&1; then
FD_AVAILABLE=1
else
FD_AVAILABLE=0
fi
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
setup() {
export TERM="xterm-256color"
export MO_DEBUG=0
# Create standard scan directories
mkdir -p "$HOME/Downloads"
mkdir -p "$HOME/Desktop"
mkdir -p "$HOME/Documents"
mkdir -p "$HOME/Public"
mkdir -p "$HOME/Library/Downloads"
# Clear previous test files
rm -rf "${HOME:?}/Downloads"/*
rm -rf "${HOME:?}/Desktop"/*
rm -rf "${HOME:?}/Documents"/*
}
require_fd() {
[[ "${FD_AVAILABLE:-0}" -eq 1 ]]
}
@test "scan_installers_in_path (fd): finds .dmg files" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/Chrome.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"Chrome.dmg"* ]]
}
@test "scan_installers_in_path (fd): finds multiple installer types" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/App1.dmg"
touch "$HOME/Downloads/App2.pkg"
touch "$HOME/Downloads/App3.iso"
touch "$HOME/Downloads/App.mpkg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App1.dmg"* ]]
[[ "$output" == *"App2.pkg"* ]]
[[ "$output" == *"App3.iso"* ]]
[[ "$output" == *"App.mpkg"* ]]
}
@test "scan_installers_in_path (fd): respects max depth" {
if ! require_fd; then
return 0
fi
mkdir -p "$HOME/Downloads/level1/level2/level3"
touch "$HOME/Downloads/shallow.dmg"
touch "$HOME/Downloads/level1/mid.dmg"
touch "$HOME/Downloads/level1/level2/deep.dmg"
touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
# Default max depth is 2
[[ "$output" == *"shallow.dmg"* ]]
[[ "$output" == *"mid.dmg"* ]]
[[ "$output" == *"deep.dmg"* ]]
[[ "$output" != *"too-deep.dmg"* ]]
}
@test "scan_installers_in_path (fd): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" {
if ! require_fd; then
return 0
fi
mkdir -p "$HOME/Downloads/level1"
touch "$HOME/Downloads/top.dmg"
touch "$HOME/Downloads/level1/nested.dmg"
run env MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c "
export MOLE_TEST_MODE=1
source \"\$1\"
scan_installers_in_path \"\$2\"
" bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"top.dmg"* ]]
[[ "$output" != *"nested.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles non-existent directory" {
if ! require_fd; then
return 0
fi
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/NonExistent"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
@test "scan_installers_in_path (fd): ignores non-installer files" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.jpg"
touch "$HOME/Downloads/archive.tar.gz"
touch "$HOME/Downloads/Installer.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" != *"document.pdf"* ]]
[[ "$output" != *"image.jpg"* ]]
[[ "$output" != *"archive.tar.gz"* ]]
[[ "$output" == *"Installer.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles filenames with spaces" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/My App Installer.dmg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"My App Installer.dmg"* ]]
}
@test "scan_installers_in_path (fd): handles filenames with special characters" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/App-v1.2.3_beta.pkg"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"App-v1.2.3_beta.pkg"* ]]
}
@test "scan_installers_in_path (fd): returns empty for directory with no installers" {
if ! require_fd; then
return 0
fi
# Create some non-installer files
touch "$HOME/Downloads/document.pdf"
touch "$HOME/Downloads/image.png"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ -z "$output" ]]
}
@test "scan_installers_in_path (fd): skips symlinks to regular files" {
if ! require_fd; then
return 0
fi
touch "$HOME/Downloads/real.dmg"
ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg"
ln -s /nonexistent "$HOME/Downloads/dangling.lnk"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
[ "$status" -eq 0 ]
[[ "$output" == *"real.dmg"* ]]
[[ "$output" != *"symlink.dmg"* ]]
[[ "$output" != *"dangling.lnk"* ]]
}

312
tests/installer_zip.bats Normal file
View File

@@ -0,0 +1,312 @@
#!/usr/bin/env bats
setup_file() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)"
export PROJECT_ROOT
ORIGINAL_HOME="${HOME:-}"
export ORIGINAL_HOME
HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-installers-home.XXXXXX")"
export HOME
mkdir -p "$HOME"
if command -v zip > /dev/null 2>&1; then
ZIP_AVAILABLE=1
else
ZIP_AVAILABLE=0
fi
if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then
ZIP_LIST_AVAILABLE=1
else
ZIP_LIST_AVAILABLE=0
fi
if command -v unzip > /dev/null 2>&1; then
UNZIP_AVAILABLE=1
else
UNZIP_AVAILABLE=0
fi
}
teardown_file() {
rm -rf "$HOME"
if [[ -n "${ORIGINAL_HOME:-}" ]]; then
export HOME="$ORIGINAL_HOME"
fi
}
setup() {
export TERM="xterm-256color"
export MO_DEBUG=0
# Create standard scan directories
mkdir -p "$HOME/Downloads"
mkdir -p "$HOME/Desktop"
mkdir -p "$HOME/Documents"
mkdir -p "$HOME/Public"
mkdir -p "$HOME/Library/Downloads"
# Clear previous test files
rm -rf "${HOME:?}/Downloads"/*
rm -rf "${HOME:?}/Desktop"/*
rm -rf "${HOME:?}/Documents"/*
}
zip_list_available() {
[[ "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]]
}
require_zip_list() {
zip_list_available
}
require_zip_support() {
[[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${ZIP_LIST_AVAILABLE:-0}" -eq 1 ]]
}
require_unzip_support() {
[[ "${ZIP_AVAILABLE:-0}" -eq 1 && "${UNZIP_AVAILABLE:-0}" -eq 1 ]]
}
# Test ZIP installer detection
@test "is_installer_zip: rejects ZIP with installer content but too many entries" {
if ! require_zip_support; then
return 0
fi
# Create a ZIP with too many files (exceeds MAX_ZIP_ENTRIES=5)
# Include a .app file to have installer content
mkdir -p "$HOME/Downloads/large-app"
touch "$HOME/Downloads/large-app/MyApp.app"
for i in {1..9}; do
touch "$HOME/Downloads/large-app/file$i.txt"
done
(cd "$HOME/Downloads" && zip -q -r large-installer.zip large-app)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/large-installer.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "NOT_INSTALLER" ]]
}
@test "is_installer_zip: detects ZIP with app content" {
if ! require_zip_support; then
return 0
fi
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/app.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "INSTALLER" ]]
}
@test "is_installer_zip: rejects ZIP with only regular files" {
if ! require_zip_support; then
return 0
fi
mkdir -p "$HOME/Downloads/data"
touch "$HOME/Downloads/data/file1.txt"
touch "$HOME/Downloads/data/file2.pdf"
(cd "$HOME/Downloads" && zip -q -r data.zip data)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
if is_installer_zip "'"$HOME/Downloads/data.zip"'"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == "NOT_INSTALLER" ]]
}
@test "is_installer_zip: returns NOT_INSTALLER when ZIP list command is unavailable" {
touch "$HOME/Downloads/empty.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
ZIP_LIST_CMD=()
if is_installer_zip "$2"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/empty.zip"
[ "$status" -eq 0 ]
[[ "$output" == "NOT_INSTALLER" ]]
}
@test "is_installer_zip: works with unzip list command" {
if ! require_unzip_support; then
return 0
fi
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r app.zip app-content)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
ZIP_LIST_CMD=(unzip -Z -1)
if is_installer_zip "$2"; then
echo "INSTALLER"
else
echo "NOT_INSTALLER"
fi
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads/app.zip"
[ "$status" -eq 0 ]
[[ "$output" == "INSTALLER" ]]
}
# Integration tests: ZIP scanning inside scan_all_installers
@test "scan_all_installers: finds installer ZIP in Downloads" {
if ! require_zip_support; then
return 0
fi
# Create a valid installer ZIP (contains .app)
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r installer.zip app-content)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" == *"installer.zip"* ]]
}
@test "scan_all_installers: ignores non-installer ZIP in Downloads" {
if ! require_zip_support; then
return 0
fi
# Create a non-installer ZIP (only regular files)
mkdir -p "$HOME/Downloads/data"
touch "$HOME/Downloads/data/file1.txt"
touch "$HOME/Downloads/data/file2.pdf"
(cd "$HOME/Downloads" && zip -q -r data.zip data)
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_all_installers
' bash "$PROJECT_ROOT/bin/installer.sh"
[ "$status" -eq 0 ]
[[ "$output" != *"data.zip"* ]]
}
# Failure path tests for scan_installers_in_path
@test "scan_installers_in_path: skips corrupt ZIP files" {
if ! require_zip_list; then
return 0
fi
# Create a corrupt ZIP file by just writing garbage data
echo "This is not a valid ZIP file" > "$HOME/Downloads/corrupt.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should succeed (return 0) and silently skip the corrupt ZIP
[ "$status" -eq 0 ]
# Output should be empty since corrupt.zip is not a valid installer
[[ -z "$output" ]]
}
@test "scan_installers_in_path: handles permission-denied files" {
if ! require_zip_support; then
return 0
fi
# Create a valid installer ZIP
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r readable.zip app-content)
# Create a readable installer ZIP alongside a permission-denied file
mkdir -p "$HOME/Downloads/restricted-app"
touch "$HOME/Downloads/restricted-app/App.app"
(cd "$HOME/Downloads" && zip -q -r restricted.zip restricted-app)
# Remove read permissions from restricted.zip
chmod 000 "$HOME/Downloads/restricted.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should succeed and find the readable.zip but skip restricted.zip
[ "$status" -eq 0 ]
[[ "$output" == *"readable.zip"* ]]
[[ "$output" != *"restricted.zip"* ]]
# Cleanup: restore permissions for teardown
chmod 644 "$HOME/Downloads/restricted.zip"
}
@test "scan_installers_in_path: finds installer ZIP alongside corrupt ZIPs" {
if ! require_zip_support; then
return 0
fi
# Create a valid installer ZIP
mkdir -p "$HOME/Downloads/app-content"
touch "$HOME/Downloads/app-content/MyApp.app"
(cd "$HOME/Downloads" && zip -q -r valid-installer.zip app-content)
# Create a corrupt ZIP
echo "garbage data" > "$HOME/Downloads/corrupt.zip"
run bash -euo pipefail -c '
export MOLE_TEST_MODE=1
source "$1"
scan_installers_in_path "$2"
' bash "$PROJECT_ROOT/bin/installer.sh" "$HOME/Downloads"
# Should find the valid ZIP and silently skip the corrupt one
[ "$status" -eq 0 ]
[[ "$output" == *"valid-installer.zip"* ]]
[[ "$output" != *"corrupt.zip"* ]]
}