diff --git a/README.md b/README.md index a6240f4..e96121e 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,11 @@ $ mo uninstall Select Apps to Remove ═══════════════════════════ -▶ ☑ Adobe Creative Cloud (9.4G) | Old - ☐ WeChat (2.1G) | Recent - ☐ Final Cut Pro (3.8G) | Recent +▶ ☑ Photoshop 2024 (4.2G) | Old + ☐ IntelliJ IDEA (2.8G) | Recent + ☐ Premiere Pro (3.4G) | Recent -Uninstalling: Adobe Creative Cloud +Uninstalling: Photoshop 2024 ✓ Removed application ✓ Cleaned 52 related files across 12 locations @@ -155,7 +155,7 @@ Analyze Disk ~/Documents | Total: 156.8GB 4. ███░░░░░░░░░░░░░░░░ 10.8% | 📁 Documents 16.9GB 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 @@ -223,6 +223,23 @@ When custom paths are configured, only those directories are scanned. Otherwise, +### 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 Launch Mole commands instantly from Raycast or Alfred: diff --git a/bin/installer.sh b/bin/installer.sh index 8c2d00c..2a1ca35 100755 --- a/bin/installer.sh +++ b/bin/installer.sh @@ -1,6 +1,6 @@ #!/bin/bash # 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 @@ -33,18 +33,32 @@ readonly INSTALLER_SCAN_PATHS=( "$HOME/Public" "$HOME/Library/Downloads" "/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 +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() { local zip="$1" 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)) \ | awk -v cap="$cap" ' /\.(app|pkg|dmg|xip)(\/|$)/ { found=1 } @@ -52,7 +66,28 @@ is_installer_zip() { if (NR > cap) exit 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() { @@ -65,18 +100,7 @@ scan_installers_in_path() { if command -v fd > /dev/null 2>&1; then while IFS= read -r file; do - [[ -L "$file" ]] && continue # Skip symlinks explicitly - 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 + handle_candidate_file "$file" done < <( fd --no-ignore --hidden --type f --max-depth "$max_depth" \ -e dmg -e pkg -e mpkg -e iso -e xip -e zip \ @@ -84,18 +108,7 @@ scan_installers_in_path() { ) else while IFS= read -r file; do - [[ -L "$file" ]] && continue # Skip symlinks explicitly - 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 + handle_candidate_file "$file" done < <( find "$path" -maxdepth "$max_depth" -type f \ \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \ @@ -118,33 +131,100 @@ declare -i total_size_freed_kb=0 # Global arrays for installer data declare -a INSTALLER_PATHS=() declare -a INSTALLER_SIZES=() +declare -a INSTALLER_SOURCES=() 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_installers() { - printf '\n' - echo -e "${BLUE}━━━ Scanning for installers ━━━${NC}" - # Clear previous results INSTALLER_PATHS=() INSTALLER_SIZES=() + INSTALLER_SOURCES=() DISPLAY_NAMES=() + # Start scanning with spinner + if [[ -t 1 ]]; then + start_inline_spinner "Scanning for installers..." + fi + # Scan all paths, deduplicate, and sort results 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 [[ -z "$file" ]] && continue 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 for file in "${all_files[@]}"; do @@ -154,16 +234,253 @@ collect_installers() { file_size=$(get_file_size "$file") 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_SIZES+=("$file_size") - DISPLAY_NAMES+=("$(basename "$file")") + INSTALLER_SOURCES+=("$source") + DISPLAY_NAMES+=("$display") done - echo -e " ${GREEN}Found ${#INSTALLER_PATHS[@]} installer(s)${NC}" + if [[ -t 1 ]]; then + stop_inline_spinner + fi 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/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_installer_menu() { if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then @@ -172,14 +489,8 @@ show_installer_menu() { echo "" - local title="Select installers to remove" MOLE_SELECTION_RESULT="" - paginated_multi_select "$title" "${DISPLAY_NAMES[@]}" - local selection_exit=$? - - if [[ $selection_exit -ne 0 ]]; then - echo "" - echo -e "${YELLOW}Cancelled${NC}" + if ! select_installers "${DISPLAY_NAMES[@]}"; then return 1 fi @@ -196,12 +507,54 @@ delete_selected_installers() { return 1 fi - printf '\n' - echo -e "${BLUE}━━━ Removing installers ━━━${NC}" + # Calculate total size for confirmation + 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_size_freed_kb=0 + + if [[ -t 1 ]]; then + start_inline_spinner "Removing installers..." + fi + for idx in "${selected_indices[@]}"; do if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then continue @@ -212,58 +565,68 @@ delete_selected_installers() { # Validate path before deletion if ! validate_path_for_deletion "$file_path"; then - echo -e " ${RED}${ICON_FAILED}${NC} Cannot delete (invalid path): $(basename "$file_path")" continue fi # Delete the file 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))) - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Deleted: $(basename "$file_path") ${GRAY}($human_size)${NC}" total_deleted=$((total_deleted + 1)) - else - echo -e " ${RED}${ICON_FAILED}${NC} Failed to delete: $(basename "$file_path")" fi done + if [[ -t 1 ]]; then + stop_inline_spinner + fi + return 0 } # Perform the installers cleanup 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 if ! collect_installers; then + if [[ -t 1 ]]; then leave_alt_screen; fi return 2 # Nothing to clean fi # Show menu if ! show_installer_menu; then + if [[ -t 1 ]]; then leave_alt_screen; fi return 1 # User cancelled 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_installers + if ! delete_selected_installers; then + return 1 + fi return 0 } show_summary() { - echo "" - local summary_heading="Cleanup complete" + local summary_heading="Installers cleaned" local -a summary_details=() if [[ $total_deleted -gt 0 ]]; then local freed_mb freed_mb=$(echo "$total_size_freed_kb" | awk '{printf "%.2f", $1/1024}') - summary_details+=("Installers removed: $total_deleted") - summary_details+=("Space freed: ${GREEN}${freed_mb}MB${NC}") - summary_details+=("Free space now: $(get_free_space)") + summary_details+=("Removed ${GREEN}$total_deleted${NC} installer(s), freed ${GREEN}${freed_mb}MB${NC}") + summary_details+=("Your Mac is cleaner now!") else summary_details+=("No installers were removed") - summary_details+=("Free space now: $(get_free_space)") fi print_summary_block "$summary_heading" "${summary_details[@]}" @@ -284,13 +647,6 @@ main() { esac 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 perform_installers local exit_code=$? @@ -304,9 +660,7 @@ main() { printf '\n' ;; 2) - printf '\n' - echo -e "${YELLOW}No installer files found in default locations${NC}" - printf '\n' + # Already handled by collect_installers ;; esac diff --git a/scripts/test.sh b/scripts/test.sh index 39991c0..11bc688 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -56,7 +56,43 @@ if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then export TERM="xterm-256color" fi 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 use_color=false if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then diff --git a/tests/installer.bats b/tests/installer.bats index 9e09ffb..e26b876 100644 --- a/tests/installer.bats +++ b/tests/installer.bats @@ -47,187 +47,6 @@ setup() { } # 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) @@ -332,126 +151,6 @@ setup() { [[ "$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" { # Don't create all scan directories, some may not exist # Only create Downloads, delete others if they exist @@ -520,109 +219,8 @@ setup() { [[ -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 -@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" { touch "$HOME/Downloads/real.dmg" ln -s "$HOME/Downloads/real.dmg" "$HOME/Downloads/symlink.dmg" diff --git a/tests/installer_fd.bats b/tests/installer_fd.bats new file mode 100644 index 0000000..10d98ff --- /dev/null +++ b/tests/installer_fd.bats @@ -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"* ]] +} diff --git a/tests/installer_zip.bats b/tests/installer_zip.bats new file mode 100644 index 0000000..ae2bc98 --- /dev/null +++ b/tests/installer_zip.bats @@ -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"* ]] +}