diff --git a/Makefile b/Makefile index d52b4e0..4db8f37 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,10 @@ # Output directory BIN_DIR := bin +# Go toolchain +GO ?= go +GO_DOWNLOAD_RETRIES ?= 3 + # Binaries ANALYZE := analyze STATUS := status @@ -18,22 +22,36 @@ LDFLAGS := -s -w all: build +# Download modules with retries to mitigate transient proxy/network EOF errors. +mod-download: + @attempt=1; \ + while [ $$attempt -le $(GO_DOWNLOAD_RETRIES) ]; do \ + echo "Downloading Go modules ($$attempt/$(GO_DOWNLOAD_RETRIES))..."; \ + if $(GO) mod download; then \ + exit 0; \ + fi; \ + sleep $$((attempt * 2)); \ + attempt=$$((attempt + 1)); \ + done; \ + echo "Go module download failed after $(GO_DOWNLOAD_RETRIES) attempts"; \ + exit 1 + # Local build (current architecture) -build: +build: mod-download @echo "Building for local architecture..." - go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC) - go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC) + $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-go $(ANALYZE_SRC) + $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-go $(STATUS_SRC) # Release build targets (run on native architectures for CGO support) -release-amd64: +release-amd64: mod-download @echo "Building release binaries (amd64)..." - GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) + GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-amd64 $(ANALYZE_SRC) + GOOS=darwin GOARCH=amd64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-amd64 $(STATUS_SRC) -release-arm64: +release-arm64: mod-download @echo "Building release binaries (arm64)..." - GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) - GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(ANALYZE)-darwin-arm64 $(ANALYZE_SRC) + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(STATUS)-darwin-arm64 $(STATUS_SRC) clean: @echo "Cleaning binaries..." diff --git a/RELEASE_TEST_CHECKLIST.md b/RELEASE_TEST_CHECKLIST.md new file mode 100644 index 0000000..759b6fd --- /dev/null +++ b/RELEASE_TEST_CHECKLIST.md @@ -0,0 +1,223 @@ +# Mole V1.29.0 发布前测试清单 + +## 1. 基础功能测试 + +### 1.1 主菜单和导航 +- [ ] `mo` - 主菜单正常显示 +- [ ] 方向键 ↑↓ 导航正常 +- [ ] Enter 进入子菜单正常 +- [ ] M 键显示帮助信息 +- [ ] Q 键退出正常 + +### 1.2 版本和帮助 +- [ ] `mo --version` - 显示 1.29.0 +- [ ] `mo --help` - 帮助信息完整 +- [ ] `mo version` - 显示详细版本信息(macOS版本、架构、SIP状态等) + +--- + +## 2. Clean 功能测试 + +### 2.1 基础清理 +- [ ] `mo clean` - 交互式清理正常 +- [ ] `mo clean --dry-run` - 预览模式显示正确 +- [ ] `mo clean --whitelist` - 白名单管理正常 + +### 2.2 新增功能: Xcode DeviceSupport +- [ ] 清理旧的 Xcode DeviceSupport 版本(而不是仅缓存) +- [ ] 保留当前使用的版本 + +### 2.3 Bug修复验证 +- [ ] Go cache 清理时尊重 whitelist +- [ ] Homebrew dry-run 模式尊重 whitelist +- [ ] pip3 是 macOS stub 时跳过 pip 缓存清理 +- [ ] 修复后的 ICON_WARNING 显示正确 + +--- + +## 3. Analyze 功能测试 + +### 3.1 基础分析 +- [ ] `mo analyze` - 交互式分析正常 +- [ ] `mo analyze /path` - 分析指定路径 +- [ ] `mo analyze /Volumes` - 分析外部磁盘 + +### 3.2 新增功能: JSON 输出 (PR #533) +- [ ] `mo analyze --json` - JSON 格式输出 +- [ ] `mo analyze --json /path` - 指定路径 JSON 输出 +- [ ] 非 TTY 环境自动使用 JSON(如管道): `mo analyze | cat` +- [ ] JSON 包含字段: path, total_size, file_count, items[] + +--- + +## 4. Status 功能测试 + +### 4.1 基础状态 +- [ ] `mo status` - 显示系统健康状态 +- [ ] CPU、内存、磁盘、网络数据显示正常 + +### 4.2 新增功能: JSON 输出 (PR #529) +- [ ] `mo status --json` - JSON 格式输出 +- [ ] 非 TTY 环境自动使用 JSON: `mo status | cat` +- [ ] JSON 包含网络数据 (PR #532 fix) +- [ ] JSON 字段验证: cpu, memory, disk, network, load_avg, uptime + +--- + +## 5. Uninstall 功能测试 + +- [ ] `mo uninstall` - 应用卸载界面正常 +- [ ] `mo uninstall --dry-run` - 预览卸载 +- [ ] `mo uninstall --whitelist` - 白名单管理 +- [ ] 卸载后能正确发现相关文件 + +--- + +## 6. Optimize 功能测试 + +- [ ] `mo optimize` - 系统优化正常 +- [ ] `mo optimize --dry-run` - 预览模式 +- [ ] `mo optimize --whitelist` - 白名单管理 + +--- + +## 7. Purge 功能测试 + +- [ ] `mo purge` - 项目清理正常 +- [ ] `mo purge --dry-run` - 预览模式 +- [ ] `mo purge --paths` - 配置扫描目录 +- [ ] dry-run 不计入失败移除 (bug fix验证) + +--- + +## 8. Installer 功能测试 + +- [ ] `mo installer` - 安装包清理正常 +- [ ] `mo installer --dry-run` - 预览模式 + +--- + +## 9. TouchID 功能测试 + +- [ ] `mo touchid` - TouchID 配置界面 +- [ ] `mo touchid enable --dry-run` - 预览模式 + +--- + +## 10. Update 功能测试 + +### 10.1 基础更新 +- [ ] `mo update` - 检查更新(当前已是最新版) +- [ ] `mo update --force` - 强制重新安装 + +### 10.2 新增功能: Nightly 更新 (PR #517) +- [ ] `mo update --nightly` - 安装 nightly 版本 +- [ ] nightly 安装后 `mo --version` 显示 commit hash +- [ ] nightly 安装后 `mo version` 显示 "Channel: Nightly (xxxxxx)" +- [ ] Homebrew 安装时 `mo update --nightly` 应被拒绝 + +### 10.3 Bug修复验证 +- [ ] 更新时保持 sudo 会话活跃 +- [ ] 避免 SIGPIPE 在 Homebrew 检测中 + +--- + +## 11. Completion 功能测试 + +- [ ] `mo completion` - 补全脚本安装 +- [ ] `mo completion --dry-run` - 预览模式 + +--- + +## 12. Remove 功能测试 + +- [ ] `mo remove --dry-run` - 预览移除 Mole + +--- + +## 13. 边界情况测试 + +### 13.1 安全性 +- [ ] 不删除 /System、/Library/Apple 等受保护路径 +- [ ] 不删除 com.apple.* 系统文件 +- [ ] dry-run 模式绝不执行实际删除 + +### 13.2 并发和超时 +- [ ] 长时间运行的命令有超时处理 +- [ ] 网络请求有超时处理 + +### 13.3 错误处理 +- [ ] 网络不可用时的优雅降级 +- [ ] 权限不足时的正确提示 +- [ ] 文件不存在时不报错 + +--- + +## 14. 多场景测试 + +### 14.1 不同安装方式 +- [ ] 脚本安装的功能正常 +- [ ] Homebrew 安装的功能正常(如果可测) + +### 14.2 不同 macOS 版本 +- [ ] 在支持的 macOS 版本上测试 + +### 14.3 不同架构 +- [ ] Apple Silicon (arm64) - 测试通过 +- [ ] Intel (x86_64) - 如可测 + +--- + +## 15. JSON 输出格式验证 + +### 15.1 Analyze JSON 结构 +```bash +mo analyze --json /tmp 2>/dev/null | jq '.' +``` +应包含: +- [ ] path +- [ ] total_size +- [ ] file_count +- [ ] items (name, path, size, size_human, count) + +### 15.2 Status JSON 结构 +```bash +mo status --json 2>/dev/null | jq '.' +``` +应包含: +- [ ] cpu (usage, cores) +- [ ] memory (total, used, free, cached, usage_percent) +- [ ] disk (total, used, free, usage_percent) +- [ ] network (interfaces with rx_bytes, tx_bytes) +- [ ] load_avg (1min, 5min, 15min) +- [ ] uptime + +--- + +## 快速验证命令 + +```bash +# 1. 版本检查 +mo --version # 应为 1.29.0 + +# 2. 核心功能快速测试 +mo clean --dry-run +mo analyze --json /tmp 2>/dev/null | head -20 +mo status --json 2>/dev/null | jq '.' + +# 3. 测试脚本验证 +./scripts/test.sh + +# 4. 代码格式检查 +./scripts/check.sh --format +``` + +--- + +## 测试通过标准 + +- [ ] 所有勾选测试通过 +- [ ] 无崩溃、无异常退出 +- [ ] JSON 输出格式正确 +- [ ] dry-run 模式安全 +- [ ] 测试脚本全部通过: 464 tests, 0 failures diff --git a/bin/uninstall.sh b/bin/uninstall.sh index b1b4f01..5c96661 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -790,6 +790,7 @@ load_applications() { # Cleanup: restore cursor and kill keepalive. cleanup() { + local exit_code="${1:-$?}" if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen unset MOLE_ALT_SCREEN_ACTIVE @@ -802,7 +803,7 @@ cleanup() { # Log session end log_operation_session_end "uninstall" "${files_cleaned:-0}" "${total_size_cleaned:-0}" show_cursor - exit "${1:-0}" + exit "$exit_code" } trap cleanup EXIT INT TERM @@ -825,6 +826,22 @@ main() { "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; + "--whitelist") + echo "Unknown uninstall option: $arg" + echo "Whitelist management is currently supported by: mo clean --whitelist / mo optimize --whitelist" + echo "Use 'mo uninstall --help' for supported options." + exit 1 + ;; + -*) + echo "Unknown uninstall option: $arg" + echo "Use 'mo uninstall --help' for supported options." + exit 1 + ;; + *) + echo "Unknown uninstall argument: $arg" + echo "Use 'mo uninstall --help' for supported options." + exit 1 + ;; esac done diff --git a/cmd/status/main.go b/cmd/status/main.go index b7eeb01..a31a8ba 100644 --- a/cmd/status/main.go +++ b/cmd/status/main.go @@ -24,6 +24,20 @@ var ( jsonOutput = flag.Bool("json", false, "output metrics as JSON instead of TUI") ) +func shouldUseJSONOutput(forceJSON bool, stdout *os.File) bool { + if forceJSON { + return true + } + if stdout == nil { + return false + } + info, err := stdout.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) == 0 +} + type tickMsg struct{} type animTickMsg struct{} @@ -246,7 +260,7 @@ func runTUIMode() { func main() { flag.Parse() - if *jsonOutput { + if shouldUseJSONOutput(*jsonOutput, os.Stdout) { runJSONMode() } else { runTUIMode() diff --git a/cmd/status/main_test.go b/cmd/status/main_test.go new file mode 100644 index 0000000..76f31cb --- /dev/null +++ b/cmd/status/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "testing" +) + +func TestShouldUseJSONOutput_ForceFlag(t *testing.T) { + if !shouldUseJSONOutput(true, nil) { + t.Fatalf("expected force JSON flag to enable JSON mode") + } +} + +func TestShouldUseJSONOutput_NilStdout(t *testing.T) { + if shouldUseJSONOutput(false, nil) { + t.Fatalf("expected nil stdout to keep TUI mode") + } +} + +func TestShouldUseJSONOutput_NonTTYPipe(t *testing.T) { + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + defer reader.Close() + defer writer.Close() + + if !shouldUseJSONOutput(false, writer) { + t.Fatalf("expected pipe stdout to use JSON mode") + } +} + +func TestShouldUseJSONOutput_NonTTYFile(t *testing.T) { + tmpFile, err := os.CreateTemp("", "mole-status-stdout-*.txt") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + if !shouldUseJSONOutput(false, tmpFile) { + t.Fatalf("expected file stdout to use JSON mode") + } +} diff --git a/lib/check/all.sh b/lib/check/all.sh index 1126826..4a6960a 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -243,6 +243,99 @@ get_software_updates() { fi } +check_homebrew_updates() { + # Check whitelist + if command -v is_whitelisted > /dev/null && is_whitelisted "check_homebrew_updates"; then return; fi + + export BREW_OUTDATED_COUNT=0 + export BREW_FORMULA_OUTDATED_COUNT=0 + export BREW_CASK_OUTDATED_COUNT=0 + + if ! command -v brew > /dev/null 2>&1; then + printf " ${GRAY}${ICON_EMPTY}${NC} %-12s %s\n" "Homebrew" "Not installed" + return + fi + + local cache_file="$CACHE_DIR/brew_updates" + local formula_count=0 + local cask_count=0 + local total_count=0 + local use_cache=false + + if is_cache_valid "$cache_file"; then + local cached_formula="" + local cached_cask="" + IFS=' ' read -r cached_formula cached_cask < "$cache_file" || true + if [[ "$cached_formula" =~ ^[0-9]+$ && "$cached_cask" =~ ^[0-9]+$ ]]; then + formula_count="$cached_formula" + cask_count="$cached_cask" + use_cache=true + fi + fi + + if [[ "$use_cache" == "false" ]]; then + local formula_outdated="" + local cask_outdated="" + local formula_status=0 + local cask_status=0 + local spinner_started=false + + if [[ -t 1 ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking Homebrew updates..." + spinner_started=true + fi + + if formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null); then + : + else + formula_status=$? + fi + + if cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null); then + : + else + cask_status=$? + fi + + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner + fi + + if [[ $formula_status -eq 0 || $cask_status -eq 0 ]]; then + formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}') + cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}') + ensure_user_file "$cache_file" + printf '%s %s\n' "$formula_count" "$cask_count" > "$cache_file" 2> /dev/null || true + elif [[ $formula_status -eq 124 || $cask_status -eq 124 ]]; then + printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check timed out" + return + else + printf " ${GRAY}${ICON_WARNING}${NC} %-12s ${YELLOW}%s${NC}\n" "Homebrew" "Check failed" + return + fi + fi + + total_count=$((formula_count + cask_count)) + export BREW_FORMULA_OUTDATED_COUNT="$formula_count" + export BREW_CASK_OUTDATED_COUNT="$cask_count" + export BREW_OUTDATED_COUNT="$total_count" + + if [[ $total_count -gt 0 ]]; then + local detail="" + if [[ $formula_count -gt 0 ]]; then + detail="${formula_count} formula" + fi + if [[ $cask_count -gt 0 ]]; then + [[ -n "$detail" ]] && detail="${detail}, " + detail="${detail}${cask_count} cask" + fi + [[ -z "$detail" ]] && detail="${total_count} updates" + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "Homebrew" "${detail} available" + else + printf " ${GREEN}✓${NC} %-12s %s\n" "Homebrew" "Up to date" + fi +} + check_appstore_updates() { # Skipped for speed optimization - consolidated into check_macos_update # We can't easily distinguish app store vs macos updates without the slow softwareupdate -l call @@ -298,9 +391,9 @@ check_macos_update() { export MACOS_UPDATE_AVAILABLE="$updates_available" if [[ "$updates_available" == "true" ]]; then - echo -e " ${GRAY}${ICON_WARNING}${NC} macOS ${YELLOW}Update available${NC}" + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" else - echo -e " ${GREEN}✓${NC} macOS System up to date" + printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date" fi } @@ -366,12 +459,12 @@ check_mole_update() { # Compare versions if [[ "$(printf '%s\n' "$current_version" "$latest_version" | sort -V | head -1)" == "$current_version" ]]; then export MOLE_UPDATE_AVAILABLE="true" - echo -e " ${GRAY}${ICON_WARNING}${NC} Mole ${YELLOW}${latest_version} available${NC}, running ${current_version}" + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}, running %s\n" "$ICON_WARNING" "Mole" "${latest_version} available" "${current_version}" else - echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" + printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" fi else - echo -e " ${GREEN}✓${NC} Mole Latest version ${current_version}" + printf " ${GREEN}✓${NC} %-12s %s\n" "Mole" "Latest version ${current_version}" fi } @@ -384,6 +477,7 @@ check_all_updates() { get_software_updates > /dev/null echo -e "${BLUE}${ICON_ARROW}${NC} System Updates" + check_homebrew_updates check_appstore_updates check_macos_update check_mole_update diff --git a/lib/clean/dev.sh b/lib/clean/dev.sh index 25ae109..342ea0f 100644 --- a/lib/clean/dev.sh +++ b/lib/clean/dev.sh @@ -645,127 +645,129 @@ clean_dev_mobile() { local unavailable_udid="" # Check if simctl is accessible and working + local simctl_available=true if ! xcrun simctl list devices > /dev/null 2>&1; then debug_log "simctl not accessible or CoreSimulator service not running" echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators · simctl not available" note_activity - return 0 + simctl_available=false fi - unavailable_before=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") - [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 - while IFS= read -r unavailable_udid; do - [[ -n "$unavailable_udid" ]] && unavailable_udids+=("$unavailable_udid") - done < <( - xcrun simctl list devices unavailable 2> /dev/null | - command sed -nE 's/.*\(([0-9A-Fa-f-]{36})\).*\(unavailable.*/\1/p' || true - ) - if [[ ${#unavailable_udids[@]} -gt 0 ]]; then - local udid - for udid in "${unavailable_udids[@]}"; do - local simulator_device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" - if [[ -d "$simulator_device_path" ]]; then - unavailable_size_kb=$((unavailable_size_kb + $(get_path_size_kb "$simulator_device_path"))) - fi - done - fi - unavailable_size_human=$(bytes_to_human "$((unavailable_size_kb * 1024))") - - if [[ "$DRY_RUN" == "true" ]]; then - if ((unavailable_before > 0)); then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode unavailable simulators · would clean ${unavailable_before}, ${unavailable_size_human}" - else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" - fi - else - # Skip if no unavailable simulators - if ((unavailable_before == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" - note_activity - return 0 + if [[ "$simctl_available" == "true" ]]; then + unavailable_before=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + [[ "$unavailable_before" =~ ^[0-9]+$ ]] || unavailable_before=0 + while IFS= read -r unavailable_udid; do + [[ -n "$unavailable_udid" ]] && unavailable_udids+=("$unavailable_udid") + done < <( + xcrun simctl list devices unavailable 2> /dev/null | + command sed -nE 's/.*\(([0-9A-Fa-f-]{36})\).*\(unavailable.*/\1/p' || true + ) + if [[ ${#unavailable_udids[@]} -gt 0 ]]; then + local udid + for udid in "${unavailable_udids[@]}"; do + local simulator_device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" + if [[ -d "$simulator_device_path" ]]; then + unavailable_size_kb=$((unavailable_size_kb + $(get_path_size_kb "$simulator_device_path"))) + fi + done fi + unavailable_size_human=$(bytes_to_human "$((unavailable_size_kb * 1024))") - start_section_spinner "Checking unavailable simulators..." - - # Capture error output for diagnostics - local delete_output - local delete_exit_code=0 - delete_output=$(xcrun simctl delete unavailable 2>&1) || delete_exit_code=$? - - if [[ $delete_exit_code -eq 0 ]]; then - stop_section_spinner - unavailable_after=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") - [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 - - removed_unavailable=$((unavailable_before - unavailable_after)) - if ((removed_unavailable < 0)); then - removed_unavailable=0 - fi - - if ((removed_unavailable > 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${unavailable_size_human}" + if [[ "$DRY_RUN" == "true" ]]; then + if ((unavailable_before > 0)); then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Xcode unavailable simulators · would clean ${unavailable_before}, ${unavailable_size_human}" else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${unavailable_size_human}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" fi else - stop_section_spinner + # Skip if no unavailable simulators + if ((unavailable_before == 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · already clean" + note_activity + else + start_section_spinner "Checking unavailable simulators..." - # Analyze error and provide helpful message - local error_hint="" - if echo "$delete_output" | grep -qi "permission denied"; then - error_hint=" (permission denied)" - elif echo "$delete_output" | grep -qi "in use\|busy"; then - error_hint=" (device in use)" - elif echo "$delete_output" | grep -qi "unable to boot\|failed to boot"; then - error_hint=" (boot failure)" - elif echo "$delete_output" | grep -qi "service"; then - error_hint=" (CoreSimulator service issue)" - fi + # Capture error output for diagnostics + local delete_output + local delete_exit_code=0 + delete_output=$(xcrun simctl delete unavailable 2>&1) || delete_exit_code=$? - # Try fallback: manual deletion of unavailable device directories - if [[ ${#unavailable_udids[@]} -gt 0 ]]; then - debug_log "Attempting fallback: manual deletion of unavailable simulators" - local manually_removed=0 - local manual_failed=0 + if [[ $delete_exit_code -eq 0 ]]; then + stop_section_spinner + unavailable_after=$(xcrun simctl list devices unavailable 2> /dev/null | command awk '/\(unavailable/ { count++ } END { print count+0 }' || echo "0") + [[ "$unavailable_after" =~ ^[0-9]+$ ]] || unavailable_after=0 - for udid in "${unavailable_udids[@]}"; do - # Validate UUID format (36 chars: 8-4-4-4-12 hex pattern) - if [[ ! "$udid" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then - debug_log "Invalid UUID format, skipping: $udid" - ((manual_failed++)) || true - continue + removed_unavailable=$((unavailable_before - unavailable_after)) + if ((removed_unavailable < 0)); then + removed_unavailable=0 fi - local device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" - if [[ -d "$device_path" ]]; then - # Use safe_remove for validated simulator device directory - if safe_remove "$device_path" true; then - ((manually_removed++)) || true - debug_log "Manually removed simulator: $udid" - else - ((manual_failed++)) || true - debug_log "Failed to manually remove simulator: $udid" - fi - fi - done - - if ((manually_removed > 0)); then - if ((manual_failed == 0)); then - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${unavailable_size_human}" + if ((removed_unavailable > 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${removed_unavailable}, ${unavailable_size_human}" else - echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · cleanup completed, ${unavailable_size_human}" fi else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" - debug_log "simctl delete error: $delete_output" + stop_section_spinner + + # Analyze error and provide helpful message + local error_hint="" + if echo "$delete_output" | grep -qi "permission denied"; then + error_hint=" (permission denied)" + elif echo "$delete_output" | grep -qi "in use\|busy"; then + error_hint=" (device in use)" + elif echo "$delete_output" | grep -qi "unable to boot\|failed to boot"; then + error_hint=" (boot failure)" + elif echo "$delete_output" | grep -qi "service"; then + error_hint=" (CoreSimulator service issue)" + fi + + # Try fallback: manual deletion of unavailable device directories + if [[ ${#unavailable_udids[@]} -gt 0 ]]; then + debug_log "Attempting fallback: manual deletion of unavailable simulators" + local manually_removed=0 + local manual_failed=0 + + for udid in "${unavailable_udids[@]}"; do + # Validate UUID format (36 chars: 8-4-4-4-12 hex pattern) + if [[ ! "$udid" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then + debug_log "Invalid UUID format, skipping: $udid" + ((manual_failed++)) || true + continue + fi + + local device_path="$HOME/Library/Developer/CoreSimulator/Devices/$udid" + if [[ -d "$device_path" ]]; then + # Use safe_remove for validated simulator device directory + if safe_remove "$device_path" true; then + ((manually_removed++)) || true + debug_log "Manually removed simulator: $udid" + else + ((manual_failed++)) || true + debug_log "Failed to manually remove simulator: $udid" + fi + fi + done + + if ((manually_removed > 0)); then + if ((manual_failed == 0)); then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Xcode unavailable simulators · removed ${manually_removed} (fallback), ${unavailable_size_human}" + else + echo -e " ${YELLOW}${ICON_WARNING}${NC} Xcode unavailable simulators · partially cleaned ${manually_removed}/${#unavailable_udids[@]}, ${unavailable_size_human}" + fi + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" + debug_log "simctl delete error: $delete_output" + fi + else + echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" + debug_log "simctl delete error: $delete_output" + fi fi - else - echo -e " ${GRAY}${ICON_WARNING}${NC} Xcode unavailable simulators cleanup failed${error_hint}" - debug_log "simctl delete error: $delete_output" fi - fi - fi - note_activity + fi # Close if ((unavailable_before == 0)) + note_activity + fi # End of simctl_available check fi # Old iOS/watchOS/tvOS DeviceSupport versions (debug symbols for connected devices). # Each iOS version creates a 1-3 GB folder of debug symbols. Only the versions diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 31ff71a..35fbaf2 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -10,16 +10,23 @@ clean_user_essentials() { if ! is_path_whitelisted "$HOME/.Trash"; then local trash_count - trash_count=$(osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null || echo "0") + local trash_count_status=0 + trash_count=$(run_with_timeout 3 osascript -e 'tell application "Finder" to count items in trash' 2> /dev/null) || trash_count_status=$? + if [[ $trash_count_status -eq 124 ]]; then + debug_log "Finder trash count timed out, using direct .Trash scan" + trash_count=$(command find "$HOME/.Trash" -mindepth 1 -maxdepth 1 -exec printf '.' ';' 2> /dev/null | + wc -c | awk '{print $1}' || echo "0") + fi [[ "$trash_count" =~ ^[0-9]+$ ]] || trash_count="0" if [[ "$DRY_RUN" == "true" ]]; then [[ $trash_count -gt 0 ]] && echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Trash · would empty, $trash_count items" || echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · already empty" elif [[ $trash_count -gt 0 ]]; then - if osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then + if run_with_timeout 5 osascript -e 'tell application "Finder" to empty trash' > /dev/null 2>&1; then echo -e " ${GREEN}${ICON_SUCCESS}${NC} Trash · emptied, $trash_count items" note_activity else + debug_log "Finder trash empty failed or timed out, falling back to direct deletion" local cleaned_count=0 while IFS= read -r -d '' item; do if safe_remove "$item" true; then @@ -435,6 +442,8 @@ clean_support_app_data() { # App caches (merged: macOS system caches + Sandboxed apps). clean_app_caches() { + start_section_spinner "Scanning app caches..." + # macOS system caches (merged from clean_macos_system_caches) safe_clean ~/Library/Saved\ Application\ State/* "Saved application states" || true safe_clean ~/Library/Caches/com.apple.photoanalysisd "Photo analysis cache" || true @@ -454,8 +463,10 @@ clean_app_caches() { safe_clean ~/Library/Application\ Support/AddressBook/Sources/*/Photos.cache "Address Book photo cache" || true clean_support_app_data - # Sandboxed app caches + # Stop initial scan indicator before entering per-group scans. stop_section_spinner + + # Sandboxed app caches safe_clean ~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/* "Wallpaper agent cache" safe_clean ~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/* "Media analysis cache" safe_clean ~/Library/Containers/com.apple.AppStore/Data/Library/Caches/* "App Store cache" diff --git a/lib/core/help.sh b/lib/core/help.sh index 2c0932e..6deb945 100644 --- a/lib/core/help.sh +++ b/lib/core/help.sh @@ -59,6 +59,7 @@ show_uninstall_help() { echo "" echo "Options:" echo " --dry-run Preview app uninstallation without making changes" + echo " --whitelist Not supported for uninstall (use clean/optimize)" echo " --debug Show detailed operation logs" echo " -h, --help Show this help message" } diff --git a/lib/manage/update.sh b/lib/manage/update.sh index 5ab9863..2dc4027 100644 --- a/lib/manage/update.sh +++ b/lib/manage/update.sh @@ -4,8 +4,8 @@ set -euo pipefail -# Format Homebrew update label for display -format_brew_update_label() { +# Format Homebrew update details for display +format_brew_update_detail() { local total="${BREW_OUTDATED_COUNT:-0}" if [[ -z "$total" || "$total" -le 0 ]]; then return @@ -18,14 +18,50 @@ format_brew_update_label() { ((formulas > 0)) && details+=("${formulas} formula") ((casks > 0)) && details+=("${casks} cask") - local detail_str=", ${total} updates" + local detail_str="${total} updates" if ((${#details[@]} > 0)); then - detail_str=", $( + detail_str="$( IFS=', ' printf '%s' "${details[*]}" )" fi - printf " %s Homebrew%s" "$ICON_LIST" "$detail_str" + printf "%s" "$detail_str" +} + +# Keep for compatibility with existing callers/tests. +format_brew_update_label() { + local detail + detail=$(format_brew_update_detail || true) + [[ -n "$detail" ]] && printf "Homebrew, %s" "$detail" +} + +populate_brew_update_counts_if_unset() { + local need_probe=false + [[ -z "${BREW_OUTDATED_COUNT:-}" ]] && need_probe=true + [[ -z "${BREW_FORMULA_OUTDATED_COUNT:-}" ]] && need_probe=true + [[ -z "${BREW_CASK_OUTDATED_COUNT:-}" ]] && need_probe=true + + if [[ "$need_probe" == "false" ]]; then + return 0 + fi + + local formula_count="${BREW_FORMULA_OUTDATED_COUNT:-0}" + local cask_count="${BREW_CASK_OUTDATED_COUNT:-0}" + + if command -v brew > /dev/null 2>&1; then + local formula_outdated="" + local cask_outdated="" + + formula_outdated=$(run_with_timeout 8 brew outdated --formula --quiet 2> /dev/null || true) + cask_outdated=$(run_with_timeout 8 brew outdated --cask --quiet 2> /dev/null || true) + + formula_count=$(printf '%s\n' "$formula_outdated" | awk 'NF {count++} END {print count + 0}') + cask_count=$(printf '%s\n' "$cask_outdated" | awk 'NF {count++} END {print count + 0}') + fi + + BREW_FORMULA_OUTDATED_COUNT="$formula_count" + BREW_CASK_OUTDATED_COUNT="$cask_count" + BREW_OUTDATED_COUNT="$((formula_count + cask_count))" } brew_has_outdated() { @@ -42,61 +78,53 @@ brew_has_outdated() { # Ask user if they want to update # Returns: 0 if yes, 1 if no ask_for_updates() { - local has_updates=false - local -a update_list=() + populate_brew_update_counts_if_unset - local brew_entry - brew_entry=$(format_brew_update_label || true) - if [[ -n "$brew_entry" ]]; then + local has_updates=false + if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then has_updates=true - update_list+=("$brew_entry") fi if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then has_updates=true - update_list+=(" ${ICON_LIST} App Store, ${APPSTORE_UPDATE_COUNT} apps") fi if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then has_updates=true - update_list+=(" ${ICON_LIST} macOS system") fi if [[ -n "${MOLE_UPDATE_AVAILABLE:-}" && "${MOLE_UPDATE_AVAILABLE}" == "true" ]]; then has_updates=true - update_list+=(" ${ICON_LIST} Mole") fi if [[ "$has_updates" == "false" ]]; then return 1 fi - echo -e "${BLUE}AVAILABLE UPDATES${NC}" - for item in "${update_list[@]}"; do - echo -e "$item" - done - echo "" - # If only Mole is relevant for automation, prompt just for Mole if [[ "${MOLE_UPDATE_AVAILABLE:-}" == "true" ]]; then - echo "" echo -ne "${YELLOW}Update Mole now?${NC} ${GRAY}Enter confirm / ESC cancel${NC}: " local key if ! key=$(read_key); then echo "skip" - echo "" return 1 fi if [[ "$key" == "ENTER" ]]; then echo "yes" - echo "" return 0 fi fi - echo "" - echo -e "${ICON_REVIEW} Run ${GREEN}brew upgrade${NC} to update" + if [[ -n "${BREW_OUTDATED_COUNT:-}" && "${BREW_OUTDATED_COUNT:-0}" -gt 0 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Run ${GREEN}brew upgrade${NC} to update" + fi + if [[ -n "${MACOS_UPDATE_AVAILABLE:-}" && "${MACOS_UPDATE_AVAILABLE}" == "true" ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}System Settings${NC} → ${GREEN}General${NC} → ${GREEN}Software Update${NC}" + fi + if [[ -n "${APPSTORE_UPDATE_COUNT:-}" && "${APPSTORE_UPDATE_COUNT:-0}" -gt 0 ]]; then + echo -e " ${GRAY}${ICON_REVIEW}${NC} Open ${GREEN}App Store${NC} → ${GREEN}Updates${NC}" + fi return 1 } diff --git a/lib/optimize/tasks.sh b/lib/optimize/tasks.sh index 1c95b0d..0f69863 100644 --- a/lib/optimize/tasks.sh +++ b/lib/optimize/tasks.sh @@ -403,7 +403,7 @@ opt_launch_services_rebuild() { fi if [[ -t 1 ]]; then - start_inline_spinner "" + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Repairing LaunchServices..." fi local lsregister diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index cb4f79e..f69bc17 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -148,16 +148,24 @@ refresh_launch_services_after_uninstall() { local success=0 set +e - "$lsregister" -gc > /dev/null 2>&1 || true - "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 + # Add 10s timeout to prevent hanging (gc is usually fast) + # run_with_timeout falls back to shell implementation if timeout command unavailable + run_with_timeout 10 "$lsregister" -gc > /dev/null 2>&1 || true + # Add 15s timeout for rebuild (can be slow on some systems) + run_with_timeout 15 "$lsregister" -r -f -domain local -domain user -domain system > /dev/null 2>&1 success=$? - if [[ $success -ne 0 ]]; then - "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + # 124 = timeout exit code (from run_with_timeout or timeout command) + if [[ $success -eq 124 ]]; then + debug_log "LaunchServices rebuild timed out, trying lighter version" + run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 + success=$? + elif [[ $success -ne 0 ]]; then + run_with_timeout 10 "$lsregister" -r -f -domain local -domain user > /dev/null 2>&1 success=$? fi set -e - [[ $success -eq 0 ]] + [[ $success -eq 0 || $success -eq 124 ]] } # Remove macOS Login Items for an app @@ -789,7 +797,9 @@ batch_uninstall_applications() { fi local autoremove_output removed_count - autoremove_output=$(HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2> /dev/null) || true + # Add 30s timeout to prevent hanging on slow brew operations + # Use run_with_timeout for consistent cross-platform behavior (has shell fallback) + autoremove_output=$(run_with_timeout 30 bash -c 'HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null' || true) removed_count=$(printf '%s\n' "$autoremove_output" | grep -c "^Uninstalling" || true) removed_count=${removed_count:-0} diff --git a/mole b/mole index ca99120..aaeefd7 100755 --- a/mole +++ b/mole @@ -88,7 +88,11 @@ is_homebrew_install() { } get_install_channel() { - local channel_file="$SCRIPT_DIR/install_channel" + # Try user config dir first (matches install.sh behavior), fallback to SCRIPT_DIR + local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" + if [[ ! -f "$channel_file" ]]; then + channel_file="$SCRIPT_DIR/install_channel" + fi local channel="stable" if [[ -f "$channel_file" ]]; then channel=$(sed -n 's/^CHANNEL=\(.*\)$/\1/p' "$channel_file" | head -1) @@ -100,7 +104,11 @@ get_install_channel() { } get_install_commit() { - local channel_file="$SCRIPT_DIR/install_channel" + # Try user config dir first (matches install.sh behavior), fallback to SCRIPT_DIR + local channel_file="${MOLE_CONFIG_DIR:-$HOME/.config/mole}/install_channel" + if [[ ! -f "$channel_file" ]]; then + channel_file="$SCRIPT_DIR/install_channel" + fi if [[ -f "$channel_file" ]]; then sed -n 's/^COMMIT_HASH=\(.*\)$/\1/p' "$channel_file" | head -1 fi diff --git a/tests/brew_uninstall.bats b/tests/brew_uninstall.bats index 2547f01..84f3528 100644 --- a/tests/brew_uninstall.bats +++ b/tests/brew_uninstall.bats @@ -122,3 +122,52 @@ EOF [ "$status" -eq 0 ] } + +@test "batch_uninstall_applications tolerates brew autoremove timeout" { + local app_bundle="$HOME/Applications/BrewTimeout.app" + mkdir -p "$app_bundle" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +request_sudo_access() { return 0; } +start_inline_spinner() { :; } +stop_inline_spinner() { :; } +get_file_owner() { whoami; } +get_path_size_kb() { echo "100"; } +bytes_to_human() { echo "$1"; } +drain_pending_input() { :; } +print_summary_block() { :; } +force_kill_app() { return 0; } +remove_apps_from_dock() { :; } +refresh_launch_services_after_uninstall() { echo "LS_REFRESH"; } + +get_brew_cask_name() { echo "brew-timeout-cask"; return 0; } +brew_uninstall_cask() { return 0; } + +run_with_timeout() { + local duration="$1" + shift + echo "TIMEOUT_CALL:$duration:$*" >> "$HOME/timeout_calls.log" + if [[ "$duration" == "30" ]]; then + return 124 + fi + "$@" +} + +selected_apps=("0|$HOME/Applications/BrewTimeout.app|BrewTimeout|com.example.brewtimeout|0|Never") +files_cleaned=0 +total_items=0 +total_size_cleaned=0 + +printf '\n' | batch_uninstall_applications + +cat "$HOME/timeout_calls.log" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"TIMEOUT_CALL:30:bash -c HOMEBREW_NO_ENV_HINTS=1 brew autoremove 2>/dev/null"* ]] + [[ "$output" == *"LS_REFRESH"* ]] +} diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index fb79662..870eb96 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -420,6 +420,80 @@ EOF [[ "$output" == *"COUNT=0"* ]] } +@test "check_homebrew_updates reports counts and exports update variables" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" + +run_with_timeout() { + local timeout="${1:-}" + shift + "$@" +} + +brew() { + if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then + printf "wget\njq\n" + return 0 + fi + if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then + printf "iterm2\n" + return 0 + fi + return 0 +} + +check_homebrew_updates +echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Homebrew"* ]] + [[ "$output" == *"2 formula, 1 cask available"* ]] + [[ "$output" == *"COUNTS=3:2:1"* ]] +} + +@test "check_homebrew_updates shows timeout warning when brew query times out" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" + +run_with_timeout() { return 124; } +brew() { return 0; } +rm -f "$HOME/.cache/mole/brew_updates" + +check_homebrew_updates +echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Homebrew"* ]] + [[ "$output" == *"Check timed out"* ]] + [[ "$output" == *"COUNTS=0:0:0"* ]] +} + +@test "check_homebrew_updates shows failure warning when brew query fails" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" + +run_with_timeout() { return 1; } +brew() { return 0; } +rm -f "$HOME/.cache/mole/brew_updates" + +check_homebrew_updates +echo "COUNTS=${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Homebrew"* ]] + [[ "$output" == *"Check failed"* ]] + [[ "$output" == *"COUNTS=0:0:0"* ]] +} + @test "check_macos_update avoids slow softwareupdate scans" { run bash --noprofile --norc << 'EOF' set -euo pipefail diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 02405db..29b5f45 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -38,6 +38,44 @@ EOF [[ "$output" != *"Trash"* ]] } +@test "clean_user_essentials falls back when Finder trash operations time out" { + mkdir -p "$HOME/.Trash" + touch "$HOME/.Trash/one.tmp" "$HOME/.Trash/two.tmp" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +DRY_RUN=false +start_section_spinner() { :; } +stop_section_spinner() { :; } +safe_clean() { :; } +note_activity() { :; } +is_path_whitelisted() { return 1; } +debug_log() { :; } +run_with_timeout() { + local _duration="$1" + shift + if [[ "$1" == "osascript" ]]; then + return 124 + fi + "$@" +} +safe_remove() { + local target="$1" + /bin/rm -rf "$target" + return 0 +} + +clean_user_essentials +[[ ! -e "$HOME/.Trash/one.tmp" ]] || exit 1 +[[ ! -e "$HOME/.Trash/two.tmp" ]] || exit 1 +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Trash · emptied, 2 items"* ]] +} + @test "clean_app_caches includes macOS system caches" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' set -euo pipefail @@ -58,6 +96,24 @@ EOF [[ "$output" == *"Saved application states"* ]] || [[ "$output" == *"App caches"* ]] } +@test "clean_app_caches shows spinner during initial app cache scan" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { echo "SPIN_START:$1"; } +stop_section_spinner() { echo "SPIN_STOP"; } +safe_clean() { :; } +clean_support_app_data() { :; } +clean_group_container_caches() { :; } + +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SPIN_START:Scanning app caches..."* ]] +} + @test "clean_support_app_data targets crash, wallpaper, and messages preview caches only" { local support_home="$HOME/support-cache-home-1" run env HOME="$support_home" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' diff --git a/tests/cli.bats b/tests/cli.bats index 83eb922..7ce8f6a 100644 --- a/tests/cli.bats +++ b/tests/cli.bats @@ -14,7 +14,7 @@ setup_file() { } teardown_file() { - rm -f "$PROJECT_ROOT/install_channel" + rm -rf "$HOME/.config/mole" rm -rf "$HOME" if [[ -n "${ORIGINAL_HOME:-}" ]]; then export HOME="$ORIGINAL_HOME" @@ -46,9 +46,8 @@ SCRIPT } setup() { - rm -rf "$HOME/.config" - mkdir -p "$HOME" - rm -f "$PROJECT_ROOT/install_channel" + rm -rf "$HOME/.config/mole" + mkdir -p "$HOME/.config/mole" } @test "mole --help prints command overview" { @@ -67,7 +66,8 @@ setup() { @test "mole --version shows nightly channel metadata" { expected_version="$(grep '^VERSION=' "$PROJECT_ROOT/mole" | head -1 | sed 's/VERSION=\"\(.*\)\"/\1/')" - cat > "$PROJECT_ROOT/install_channel" <<'EOF' + mkdir -p "$HOME/.config/mole" + cat > "$HOME/.config/mole/install_channel" <<'EOF' CHANNEL=nightly EOF @@ -83,6 +83,12 @@ EOF [[ "$output" == *"Unknown command: unknown-command"* ]] } +@test "mole uninstall --whitelist returns unsupported option error" { + run env HOME="$HOME" "$PROJECT_ROOT/mole" uninstall --whitelist + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown uninstall option: --whitelist"* ]] +} + @test "touchid status reports current configuration" { run env HOME="$HOME" "$PROJECT_ROOT/mole" touchid status [ "$status" -eq 0 ] diff --git a/tests/dev_extended.bats b/tests/dev_extended.bats index 18f44a5..f50133a 100644 --- a/tests/dev_extended.bats +++ b/tests/dev_extended.bats @@ -299,3 +299,27 @@ EOF [[ "$output" == *"$volumes_root/unused-runtime"* ]] [[ "$output" != *"$volumes_root/in-use-runtime"* ]] } + +@test "clean_dev_mobile continues cleanup when simctl is unavailable" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/dev.sh" + +check_android_ndk() { :; } +clean_xcode_documentation_cache() { :; } +clean_xcode_simulator_runtime_volumes() { :; } +clean_xcode_device_support() { echo "DEVICE_SUPPORT:$2"; } +safe_clean() { echo "SAFE_CLEAN:$2"; } +note_activity() { :; } +debug_log() { :; } +xcrun() { return 1; } + +clean_dev_mobile +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"simctl not available"* ]] + [[ "$output" == *"DEVICE_SUPPORT:iOS DeviceSupport"* ]] + [[ "$output" == *"SAFE_CLEAN:Android SDK cache"* ]] +} diff --git a/tests/scripts.bats b/tests/scripts.bats index 57a521f..8dc0edc 100644 --- a/tests/scripts.bats +++ b/tests/scripts.bats @@ -56,7 +56,7 @@ setup() { } @test "Makefile has build target for Go binaries" { - run bash -c "grep -q 'go build' '$PROJECT_ROOT/Makefile'" + run bash -c "grep -Eq '(^|[[:space:]])(go|\\$\\(GO\\))[[:space:]]+build' '$PROJECT_ROOT/Makefile'" [ "$status" -eq 0 ] } diff --git a/tests/uninstall.bats b/tests/uninstall.bats index bd71faa..e6bfe27 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -313,6 +313,49 @@ EOF [ "$status" -eq 0 ] } +@test "refresh_launch_services_after_uninstall falls back after timeout" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/uninstall/batch.sh" + +log_file="$HOME/lsregister-timeout.log" +: > "$log_file" +call_index=0 + +get_lsregister_path() { echo "/bin/echo"; } +debug_log() { echo "DEBUG:$*" >> "$log_file"; } +run_with_timeout() { + local duration="$1" + shift + call_index=$((call_index + 1)) + echo "CALL${call_index}:$duration:$*" >> "$log_file" + + if [[ "$call_index" -eq 2 ]]; then + return 124 + fi + if [[ "$call_index" -eq 3 ]]; then + return 124 + fi + return 0 +} + +if refresh_launch_services_after_uninstall; then + echo "RESULT:ok" +else + echo "RESULT:fail" +fi + +cat "$log_file" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"RESULT:ok"* ]] + [[ "$output" == *"CALL2:15:/bin/echo -r -f -domain local -domain user -domain system"* ]] + [[ "$output" == *"CALL3:10:/bin/echo -r -f -domain local -domain user"* ]] + [[ "$output" == *"DEBUG:LaunchServices rebuild timed out, trying lighter version"* ]] +} + @test "remove_mole deletes manual binaries and caches" { mkdir -p "$HOME/.local/bin" touch "$HOME/.local/bin/mole" diff --git a/tests/update.bats b/tests/update.bats index 6385eaa..6473418 100644 --- a/tests/update.bats +++ b/tests/update.bats @@ -78,10 +78,32 @@ ask_for_updates EOF [ "$status" -eq 1 ] # ESC cancels - [[ "$output" == *"Homebrew, 3 formula, 2 cask"* ]] - [[ "$output" == *"App Store, 1 apps"* ]] - [[ "$output" == *"macOS system"* ]] - [[ "$output" == *"Mole"* ]] + [[ "$output" == *"Update Mole now?"* ]] + [[ "$output" == *"Run "* ]] + [[ "$output" == *"brew upgrade"* ]] + [[ "$output" == *"Software Update"* ]] + [[ "$output" == *"App Store"* ]] + [[ "$output" != *"AVAILABLE UPDATES"* ]] +} + +@test "ask_for_updates with only macOS update shows settings hint without brew hint" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/manage/update.sh" +BREW_OUTDATED_COUNT=0 +BREW_FORMULA_OUTDATED_COUNT=0 +BREW_CASK_OUTDATED_COUNT=0 +APPSTORE_UPDATE_COUNT=0 +MACOS_UPDATE_AVAILABLE=true +MOLE_UPDATE_AVAILABLE=false +ask_for_updates +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"Software Update"* ]] + [[ "$output" != *"brew upgrade"* ]] + [[ "$output" != *"AVAILABLE UPDATES"* ]] } @test "ask_for_updates accepts Enter when updates exist" { @@ -97,10 +119,50 @@ ask_for_updates EOF [ "$status" -eq 0 ] - [[ "$output" == *"AVAILABLE UPDATES"* ]] + [[ "$output" == *"Update Mole now?"* ]] [[ "$output" == *"yes"* ]] } +@test "ask_for_updates auto-detects brew updates when counts are unset" { + run bash --noprofile --norc <<'EOF' +set -euo pipefail + +cat > "$MOCK_BIN_DIR/brew" <<'SCRIPT' +#!/usr/bin/env bash +if [[ "$1" == "outdated" && "$2" == "--formula" && "$3" == "--quiet" ]]; then + printf "wget\njq\n" + exit 0 +fi +if [[ "$1" == "outdated" && "$2" == "--cask" && "$3" == "--quiet" ]]; then + printf "iterm2\n" + exit 0 +fi +exit 0 +SCRIPT +chmod +x "$MOCK_BIN_DIR/brew" + +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/manage/update.sh" +unset BREW_OUTDATED_COUNT BREW_FORMULA_OUTDATED_COUNT BREW_CASK_OUTDATED_COUNT +APPSTORE_UPDATE_COUNT=0 +MACOS_UPDATE_AVAILABLE=false +MOLE_UPDATE_AVAILABLE=false + +set +e +ask_for_updates +ask_status=$? +set -e + +echo "COUNTS:${BREW_OUTDATED_COUNT}:${BREW_FORMULA_OUTDATED_COUNT}:${BREW_CASK_OUTDATED_COUNT}" +exit "$ask_status" +EOF + + [ "$status" -eq 1 ] + [[ "$output" == *"brew upgrade"* ]] + [[ "$output" == *"COUNTS:3:2:1"* ]] + [[ "$output" != *"AVAILABLE UPDATES"* ]] +} + @test "format_brew_update_label lists formula and cask counts" { run bash --noprofile --norc <<'EOF' set -euo pipefail