From a2f071fd48c6f8b13bc34251867466a6e8be2b27 Mon Sep 17 00:00:00 2001 From: Jack Phallen Date: Fri, 2 Jan 2026 21:39:16 -0800 Subject: [PATCH] feat: Create utility to find stale app installers --- README.md | 1 + bin/installers.sh | 341 +++++++++++++++++++++ lib/core/commands.sh | 1 + mole | 4 + tests/installers.bats | 669 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1016 insertions(+) create mode 100755 bin/installers.sh create mode 100644 tests/installers.bats diff --git a/README.md b/README.md index 3984183..22f4346 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ mo optimize # Refresh caches & services mo analyze # Visual disk explorer mo status # Live system health dashboard mo purge # Clean project build artifacts +mo installers # Find and remove installer files mo touchid # Configure Touch ID for sudo mo completion # Setup shell tab completion diff --git a/bin/installers.sh b/bin/installers.sh new file mode 100755 index 0000000..2f788cb --- /dev/null +++ b/bin/installers.sh @@ -0,0 +1,341 @@ +#!/bin/bash +# Mole - Installers command +# Highlights and helps remove installer files (.dmg, .pkg, .mpkg, .iso, .xip, .zip) + +set -euo pipefail + +# shellcheck disable=SC2154 +# External variables set by menu_paginated.sh and environment +declare MOLE_SELECTION_RESULT +declare MOLE_INSTALLER_SCAN_MAX_DEPTH + +export LC_ALL=C +export LANG=C + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/core/common.sh" +source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" + + +cleanup() { + show_cursor + cleanup_temp_files +} +trap cleanup EXIT +trap 'trap - EXIT; cleanup; exit 130' INT TERM + +# Scan configuration +readonly INSTALLER_SCAN_MAX_DEPTH_DEFAULT=2 +readonly INSTALLER_SCAN_PATHS=( + "$HOME/Downloads" + "$HOME/Desktop" + "$HOME/Documents" + "$HOME/Public" + "$HOME/Library/Downloads" + "/Users/Shared" + "/Users/Shared/Downloads" # Search one level deeper +) +readonly MAX_ZIP_ENTRIES=5 + +# 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 + + zipinfo -1 "$zip" 2>/dev/null \ + | head -n $((cap + 1)) \ + | awk -v cap="$cap" ' + /\.(app|pkg|dmg|xip)(\/|$)/ { found=1 } + END { + if (NR > cap) exit 1 + exit found ? 0 : 1 + } + ' +} + +scan_installers_in_path() { + local path="$1" + local max_depth="${MOLE_INSTALLER_SCAN_MAX_DEPTH:-$INSTALLER_SCAN_MAX_DEPTH_DEFAULT}" + + [[ -d "$path" ]] || return 0 + + local file + + 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 + done < <( + fd --no-ignore --hidden --type f --max-depth "$max_depth" \ + -e dmg -e pkg -e mpkg -e iso -e xip -e zip \ + . "$path" 2>/dev/null || true + ) + 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 + done < <( + find "$path" -maxdepth "$max_depth" -type f \ + \( -name '*.dmg' -o -name '*.pkg' -o -name '*.mpkg' \ + -o -name '*.iso' -o -name '*.xip' -o -name '*.zip' \) \ + 2>/dev/null || true + ) + fi +} + +scan_all_installers() { + for path in "${INSTALLER_SCAN_PATHS[@]}"; do + scan_installers_in_path "$path" + done +} + +# Initialize stats +declare -i total_deleted=0 +declare -i total_size_freed_kb=0 + +# Global arrays for installer data +declare -a INSTALLER_PATHS=() +declare -a INSTALLER_SIZES=() +declare -a DISPLAY_NAMES=() + +# Collect all installers with their metadata +collect_installers() { + printf '\n' + echo -e "${BLUE}━━━ Scanning for installers ━━━${NC}" + + # Clear previous results + INSTALLER_PATHS=() + INSTALLER_SIZES=() + DISPLAY_NAMES=() + + # 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" + + # Process each installer + for file in "${all_files[@]}"; do + # Calculate file size + local file_size=0 + if [[ -f "$file" ]]; then + file_size=$(get_file_size "$file") + fi + + # Store installer path and size in parallel arrays + INSTALLER_PATHS+=("$file") + INSTALLER_SIZES+=("$file_size") + DISPLAY_NAMES+=("$(basename "$file")") + done + + echo -e " ${GREEN}Found ${#INSTALLER_PATHS[@]} installer(s)${NC}" + return 0 +} + +# Show menu for user selection +show_installer_menu() { + if [[ ${#DISPLAY_NAMES[@]} -eq 0 ]]; then + return 1 + fi + + 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}" + return 1 + fi + + return 0 +} + +# Delete selected installers +delete_selected_installers() { + # Parse selection indices + local -a selected_indices=() + [[ -n "$MOLE_SELECTION_RESULT" ]] && IFS=',' read -ra selected_indices <<<"$MOLE_SELECTION_RESULT" + + if [[ ${#selected_indices[@]} -eq 0 ]]; then + return 1 + fi + + printf '\n' + echo -e "${BLUE}━━━ Removing installers ━━━${NC}" + + # Delete each selected installer + total_deleted=0 + total_size_freed_kb=0 + for idx in "${selected_indices[@]}"; do + if [[ ! "$idx" =~ ^[0-9]+$ ]] || [[ $idx -ge ${#INSTALLER_PATHS[@]} ]]; then + continue + fi + + local file_path="${INSTALLER_PATHS[$idx]}" + local file_size="${INSTALLER_SIZES[$idx]}" + + # 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 + + return 0 +} + +# Perform the installers cleanup +perform_installers() { + # Collect installers + if ! collect_installers; then + return 2 # Nothing to clean + fi + + # Show menu + if ! show_installer_menu; then + return 1 # User cancelled + fi + + # Delete selected + delete_selected_installers + + return 0 +} + +show_summary() { + echo "" + local summary_heading="Cleanup complete" + 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)") + else + summary_details+=("No installers were removed") + summary_details+=("Free space now: $(get_free_space)") + fi + + print_summary_block "$summary_heading" "${summary_details[@]}" + printf '\n' +} + +show_help() { + echo -e "${PURPLE_BOLD}Mole Installers${NC} - Find and remove installer files" + echo "" + echo -e "${YELLOW}Usage:${NC} mo installers [options]" + echo "" + echo -e "${YELLOW}Options:${NC}" + echo " --debug Enable debug logging" + echo " --help Show this help message" + echo "" + echo -e "${YELLOW}Default Paths${NC}" + echo " - ${HOME}/Downloads" + echo " - ${HOME}/Desktop" + echo " - ${HOME}/Documents" + echo " - ${HOME}/Public" + echo " - ${HOME}/Library/Downloads" + echo " - /Users/Shared" +} + +main() { + for arg in "$@"; do + case "$arg" in + "--help") + show_help + exit 0 + ;; + "--debug") + export MO_DEBUG=1 + ;; + *) + echo "Unknown option: $arg" + echo "Use 'mo installers --help' for usage information" + exit 1 + ;; + esac + done + + # Clear screen for better UX + if [[ -t 1 ]]; then + printf '\033[2J\033[H' + fi + printf '\n' + echo -e "${PURPLE_BOLD}Find & Remove Installers${NC}" + + hide_cursor + perform_installers + local exit_code=$? + show_cursor + + case $exit_code in + 0) + show_summary + ;; + 1) + printf '\n' + ;; + 2) + printf '\n' + echo -e "${YELLOW}No installer files found in default locations${NC}" + printf '\n' + ;; + esac + + return 0 +} + +# Only run main if not in test mode +if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then + main "$@" +fi diff --git a/lib/core/commands.sh b/lib/core/commands.sh index 2a8aec0..c0eaa9e 100644 --- a/lib/core/commands.sh +++ b/lib/core/commands.sh @@ -8,6 +8,7 @@ MOLE_COMMANDS=( "analyze:Explore disk usage" "status:Monitor system health" "purge:Remove old project artifacts" + "installers:Find and remove installer files" "touchid:Configure Touch ID for sudo" "completion:Setup shell tab completion" "update:Update to latest version" diff --git a/mole b/mole index ed0814a..9ebce6f 100755 --- a/mole +++ b/mole @@ -254,6 +254,7 @@ show_help() { printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --dry-run" "$NC" "Preview optimization" printf " %s%-28s%s %s\n" "$GREEN" "mo optimize --whitelist" "$NC" "Manage protected items" printf " %s%-28s%s %s\n" "$GREEN" "mo purge --paths" "$NC" "Configure scan directories" + printf " %s%-28s%s %s\n" "$GREEN" "mo installers --debug" "$NC" "Find installer files" echo printf "%s%s%s\n" "$BLUE" "OPTIONS" "$NC" printf " %s%-28s%s %s\n" "$GREEN" "--debug" "$NC" "Show detailed operation logs" @@ -780,6 +781,9 @@ main() { "purge") exec "$SCRIPT_DIR/bin/purge.sh" "${args[@]:1}" ;; + "installers") + exec "$SCRIPT_DIR/bin/installers.sh" "${args[@]:1}" + ;; "touchid") exec "$SCRIPT_DIR/bin/touchid.sh" "${args[@]:1}" ;; diff --git a/tests/installers.bats b/tests/installers.bats new file mode 100644 index 0000000..12e241e --- /dev/null +++ b/tests/installers.bats @@ -0,0 +1,669 @@ +#!/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" +} + +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"/* +} + +# Test help and arguments + +@test "installers.sh --help shows usage information" { + run "$PROJECT_ROOT/bin/installers.sh" --help + + [ "$status" -eq 0 ] + [[ "$output" == *"Mole Installers"* ]] + [[ "$output" == *"Usage:"* ]] + [[ "$output" == *"mo installers"* ]] +} + +@test "installers.sh --help lists options and paths" { + run "$PROJECT_ROOT/bin/installers.sh" --help + + [ "$status" -eq 0 ] + [[ "$output" == *"--debug"* ]] + [[ "$output" == *"--help"* ]] + [[ "$output" == *"mo installers"* ]] +} + +@test "installers.sh --help shows scan scope" { + run "$PROJECT_ROOT/bin/installers.sh" --help + + [ "$status" -eq 0 ] + [[ "$output" == *"Downloads"* ]] + [[ "$output" == *"Desktop"* ]] + [[ "$output" == *"Documents"* ]] +} + +@test "installers.sh rejects unknown options" { + run "$PROJECT_ROOT/bin/installers.sh" --unknown-option + + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown option"* ]] + [[ "$output" == *"--help"* ]] +} + +# 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/installers.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/installers.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/installers.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/installers.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/installers.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/installers.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/installers.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/installers.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/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ -z "$output" ]] +} + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Tests using find (forced fallback by hiding fd) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +@test "scan_installers_in_path (fallback find): finds .dmg files" { + touch "$HOME/Downloads/Chrome.dmg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"Chrome.dmg"* ]] +} + +@test "scan_installers_in_path (fallback find): finds multiple installer types" { + touch "$HOME/Downloads/App1.dmg" + touch "$HOME/Downloads/App2.pkg" + touch "$HOME/Downloads/App3.iso" + touch "$HOME/Downloads/App.mpkg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"App1.dmg"* ]] + [[ "$output" == *"App2.pkg"* ]] + [[ "$output" == *"App3.iso"* ]] + [[ "$output" == *"App.mpkg"* ]] +} + +@test "scan_installers_in_path (fallback find): respects max depth" { + mkdir -p "$HOME/Downloads/level1/level2/level3" + touch "$HOME/Downloads/shallow.dmg" + touch "$HOME/Downloads/level1/mid.dmg" + touch "$HOME/Downloads/level1/level2/deep.dmg" + touch "$HOME/Downloads/level1/level2/level3/too-deep.dmg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.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 (fallback find): honors MOLE_INSTALLER_SCAN_MAX_DEPTH" { + mkdir -p "$HOME/Downloads/level1" + touch "$HOME/Downloads/top.dmg" + touch "$HOME/Downloads/level1/nested.dmg" + + run env PATH="/usr/bin:/bin" MOLE_INSTALLER_SCAN_MAX_DEPTH=1 bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"top.dmg"* ]] + [[ "$output" != *"nested.dmg"* ]] +} + +@test "scan_installers_in_path (fallback find): handles non-existent directory" { + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/NonExistent" + + [ "$status" -eq 0 ] + [[ -z "$output" ]] +} + +@test "scan_installers_in_path (fallback find): ignores non-installer files" { + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.jpg" + touch "$HOME/Downloads/archive.tar.gz" + touch "$HOME/Downloads/Installer.dmg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" != *"document.pdf"* ]] + [[ "$output" != *"image.jpg"* ]] + [[ "$output" != *"archive.tar.gz"* ]] + [[ "$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/installers.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/installers.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/installers.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/installers.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/installers.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 + rm -rf "$HOME/Desktop" + rm -rf "$HOME/Documents" + rm -rf "$HOME/Public" + rm -rf "$HOME/Public/Downloads" + rm -rf "$HOME/Library/Downloads" + mkdir -p "$HOME/Downloads" + + # Add an installer to the one directory that exists + touch "$HOME/Downloads/test.dmg" + + run bash -euo pipefail -c ' + export MOLE_TEST_MODE=1 + source "$1" + scan_all_installers + ' bash "$PROJECT_ROOT/bin/installers.sh" + + # Should succeed even with missing paths + [ "$status" -eq 0 ] + # Should still find the installer in the existing directory + [[ "$output" == *"test.dmg"* ]] +} + +# Test edge cases + +@test "scan_installers_in_path (fallback find): handles filenames with spaces" { + touch "$HOME/Downloads/My App Installer.dmg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"My App Installer.dmg"* ]] +} + +@test "scan_installers_in_path (fallback find): handles filenames with special characters" { + touch "$HOME/Downloads/App-v1.2.3_beta.pkg" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"App-v1.2.3_beta.pkg"* ]] +} + +@test "scan_installers_in_path (fallback find): returns empty for directory with no installers" { + # Create some non-installer files + touch "$HOME/Downloads/document.pdf" + touch "$HOME/Downloads/image.png" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ -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/installers.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/installers.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/installers.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/installers.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" + ln -s /nonexistent "$HOME/Downloads/dangling.lnk" + + run env PATH="/usr/bin:/bin" bash -euo pipefail -c " + export MOLE_TEST_MODE=1 + source \"\$1\" + scan_installers_in_path \"\$2\" + " bash "$PROJECT_ROOT/bin/installers.sh" "$HOME/Downloads" + + [ "$status" -eq 0 ] + [[ "$output" == *"real.dmg"* ]] + [[ "$output" != *"symlink.dmg"* ]] + [[ "$output" != *"dangling.lnk"* ]] +}