From 479cba6ca3534303146315d0b9838d118828d015 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 8 Dec 2025 15:33:23 +0800 Subject: [PATCH] Automated test optimization increased to 132 --- .github/workflows/quality.yml | 63 ++++++ .github/workflows/shell-quality-checks.yml | 55 ----- .github/workflows/tests.yml | 140 +++++++++++++ tests/regression_bugs.bats | 169 +++++++++++++++ tests/timeout_tests.bats | 230 +++++++++++++++++++++ tests/uninstall.bats | 37 +++- 6 files changed, 632 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/quality.yml delete mode 100644 .github/workflows/shell-quality-checks.yml create mode 100644 .github/workflows/tests.yml create mode 100644 tests/regression_bugs.bats create mode 100644 tests/timeout_tests.bats diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..11dd59e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,63 @@ +name: Quality + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: write + +jobs: + shell-quality: + name: Code Quality + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache Homebrew + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/Homebrew + /usr/local/Cellar/shfmt + /usr/local/Cellar/shellcheck + key: ${{ runner.os }}-brew-quality-${{ hashFiles('**/Brewfile') }} + restore-keys: | + ${{ runner.os }}-brew-quality- + + - name: Install tools + run: brew install shfmt shellcheck + + - name: Format check + run: | + echo "Checking shell script formatting..." + ./scripts/format.sh + if [[ -n $(git status --porcelain) ]]; then + echo "Code formatting issues found:" + git diff + exit 1 + fi + echo "✓ All scripts properly formatted" + + - name: ShellCheck + run: | + echo "Running ShellCheck on all shell scripts..." + shellcheck mole + shellcheck bin/*.sh + find lib -name "*.sh" -exec shellcheck {} + + echo "✓ ShellCheck passed" + + - name: Syntax check + run: | + echo "Checking Bash syntax..." + bash -n mole + for script in bin/*.sh; do + bash -n "$script" + done + find lib -name "*.sh" | while read -r script; do + bash -n "$script" + done + echo "✓ All scripts have valid syntax" diff --git a/.github/workflows/shell-quality-checks.yml b/.github/workflows/shell-quality-checks.yml deleted file mode 100644 index 4017961..0000000 --- a/.github/workflows/shell-quality-checks.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Shell Script Quality Checks - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: write - -jobs: - shell-quality-checks: - runs-on: macos-latest - - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.24.6" - cache: true - - - name: Cache Homebrew packages - uses: actions/cache@v4 - with: - path: | - ~/Library/Caches/Homebrew - /usr/local/Cellar/bats-core - /usr/local/Cellar/shfmt - /usr/local/Cellar/shellcheck - key: ${{ runner.os }}-brew-v1 - restore-keys: | - ${{ runner.os }}-brew- - - - name: Install shell linting and testing tools - run: brew install bats-core shfmt shellcheck - - - name: Auto-format shell scripts with shfmt - run: ./scripts/format.sh - - - name: Run shellcheck linter and bats tests - run: | - ./scripts/check.sh - echo "✓ All quality checks passed" - - - name: Run all bats tests with summary - run: | - echo "Running all test suites..." - bats tests/*.bats --formatter tap - echo "" - echo "Test summary:" - echo " Total test files: $(ls tests/*.bats | wc -l | tr -d ' ')" - echo " Total tests: $(grep -c "^@test" tests/*.bats | awk -F: '{sum+=$2} END {print sum}')" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c5d9630 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,140 @@ +name: Tests + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + unit-tests: + name: Unit Tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: brew install bats-core + + - name: Run all test suites + run: | + echo "Running all test suites..." + bats tests/*.bats --formatter tap + echo "" + echo "Test summary:" + echo " Total test files: $(ls tests/*.bats | wc -l | tr -d ' ')" + echo " Total tests: $(grep -c "^@test" tests/*.bats | awk -F: '{sum+=$2} END {print sum}')" + echo "✓ All tests passed" + + go-tests: + name: Go Tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build Go binaries + run: | + echo "Building Go binaries..." + go build ./... + echo "✓ Build successful" + + - name: Run go vet + run: | + echo "Running go vet..." + go vet ./cmd/... + echo "✓ Vet passed" + + - name: Check formatting + run: | + echo "Checking Go formatting..." + if [ -n "$(gofmt -l ./cmd)" ]; then + echo "Go code is not formatted:" + gofmt -d ./cmd + exit 1 + fi + echo "✓ Go code properly formatted" + + integration-tests: + name: Integration Tests + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: brew install coreutils + + - name: Test module loading + run: | + echo "Testing module loading..." + bash -c 'source lib/core/common.sh && echo "✓ Modules loaded successfully"' + + - name: Test clean --dry-run + run: | + echo "Testing clean --dry-run..." + ./bin/clean.sh --dry-run + echo "✓ Clean dry-run completed" + + - name: Test installation + run: | + echo "Testing installation script..." + ./install.sh --prefix /tmp/mole-test + test -f /tmp/mole-test/mole + echo "✓ Installation successful" + + compatibility: + name: macOS Compatibility + strategy: + matrix: + os: [macos-13, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Test on ${{ matrix.os }} + run: | + echo "Testing on ${{ matrix.os }}..." + bash -n mole + source lib/core/common.sh + echo "✓ Successfully loaded on ${{ matrix.os }}" + + security: + name: Security Checks + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for unsafe rm usage + run: | + echo "Checking for unsafe rm patterns..." + if grep -r "rm -rf" --include="*.sh" lib/ | grep -v "safe_remove\|validate_path\|# "; then + echo "✗ Unsafe rm -rf usage found" + exit 1 + fi + echo "✓ No unsafe rm usage found" + + - name: Verify app protection + run: | + echo "Verifying critical file protection..." + bash -c ' + source lib/core/common.sh + if should_protect_from_uninstall "com.apple.Safari"; then + echo "✓ Safari is protected" + else + echo "✗ Safari protection failed" + exit 1 + fi + ' + + - name: Check for secrets + run: | + echo "Checking for hardcoded secrets..." + if grep -r "password\|secret\|api_key" --include="*.sh" . | grep -v "# \|test"; then + echo "✗ Potential secrets found" + exit 1 + fi + echo "✓ No secrets found" diff --git a/tests/regression_bugs.bats b/tests/regression_bugs.bats new file mode 100644 index 0000000..d35a503 --- /dev/null +++ b/tests/regression_bugs.bats @@ -0,0 +1,169 @@ +#!/usr/bin/env bats +# Regression tests for previously fixed bugs +# Ensures历史bug不再复现 + +setup() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + export HOME="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME/.config/mole" +} + +# ================================================================= +# 退出问题回归测试 (bb21bb1, 4b6c436, d75c34d) +# ================================================================= + +@test "find with non-existent directory doesn't cause script exit (pipefail bug)" { + # 这个模式曾导致 lib/clean/user.sh 在 pipefail 模式下意外退出 + result=$(bash -c ' + set -euo pipefail + find /non/existent/dir -name "*.cache" 2>/dev/null || true + echo "survived" + ') + [[ "$result" == "survived" ]] +} + +@test "browser directory check pattern is safe when directories don't exist" { + # 修复模式:先检查目录是否存在 + result=$(bash -c ' + set -euo pipefail + search_dirs=() + [[ -d "/non/existent/chrome" ]] && search_dirs+=("/non/existent/chrome") + [[ -d "/tmp" ]] && search_dirs+=("/tmp") + + if [[ ${#search_dirs[@]} -gt 0 ]]; then + find "${search_dirs[@]}" -maxdepth 1 -type f 2>/dev/null || true + fi + echo "survived" + ') + [[ "$result" == "survived" ]] +} + +@test "empty array doesn't cause unbound variable error" { + result=$(bash -c ' + set -euo pipefail + search_dirs=() + + # 这不应该执行且不应该报错 + if [[ ${#search_dirs[@]} -gt 0 ]]; then + echo "should not reach here" + fi + echo "survived" + ') + [[ "$result" == "survived" ]] +} + +# =============================================================== +# 更新检查回归测试 (260254f, b61b3f7, 2a64cae, 7a9c946) +# =============================================================== + +@test "version comparison works correctly" { + result=$(bash -c ' + v1="1.11.8" + v2="1.11.9" + if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then + echo "update_needed" + fi + ') + [[ "$result" == "update_needed" ]] +} + +@test "version comparison with same versions" { + result=$(bash -c ' + v1="1.11.8" + v2="1.11.8" + if [[ "$(printf "%s\n" "$v1" "$v2" | sort -V | head -1)" == "$v1" && "$v1" != "$v2" ]]; then + echo "update_needed" + else + echo "up_to_date" + fi + ') + [[ "$result" == "up_to_date" ]] +} + +@test "version prefix v/V is stripped correctly" { + result=$(bash -c ' + version="v1.11.9" + clean=${version#v} + clean=${clean#V} + echo "$clean" + ') + [[ "$result" == "1.11.9" ]] +} + +@test "network timeout prevents hanging (simulated)" { + # curl 超时参数应该生效 + # shellcheck disable=SC2016 + result=$(timeout 5 bash -c ' + result=$(curl -fsSL --connect-timeout 1 --max-time 2 "http://192.0.2.1:12345/test" 2>/dev/null || echo "failed") + if [[ "$result" == "failed" ]]; then + echo "timeout_works" + fi + ') + [[ "$result" == "timeout_works" ]] +} + +@test "empty version string is handled gracefully" { + result=$(bash -c ' + latest="" + if [[ -z "$latest" ]]; then + echo "handled" + fi + ') + [[ "$result" == "handled" ]] +} + +# =============================================================== +# Pipefail 模式安全模式测试 +# =============================================================== + +@test "grep with no match doesn't cause exit in pipefail mode" { + result=$(bash -c ' + set -euo pipefail + echo "test" | grep "nonexistent" || true + echo "survived" + ') + [[ "$result" == "survived" ]] +} + +@test "command substitution failure is handled with || true" { + result=$(bash -c ' + set -euo pipefail + output=$(false) || true + echo "survived" + ') + [[ "$result" == "survived" ]] +} + +@test "arithmetic on zero doesn't cause exit" { + result=$(bash -c ' + set -euo pipefail + count=0 + ((count++)) || true + echo "$count" + ') + [[ "$result" == "1" ]] +} + +# =============================================================== +# 实际场景回归测试 +# =============================================================== + +@test "safe_remove pattern doesn't fail on non-existent path" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/common.sh' + safe_remove '$HOME/non/existent/path' true > /dev/null 2>&1 || true + echo 'survived' + ") + [[ "$result" == "survived" ]] +} + +@test "module loading doesn't fail" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/common.sh' + echo 'loaded' + ") + [[ "$result" == "loaded" ]] +} diff --git a/tests/timeout_tests.bats b/tests/timeout_tests.bats new file mode 100644 index 0000000..1f84257 --- /dev/null +++ b/tests/timeout_tests.bats @@ -0,0 +1,230 @@ +#!/usr/bin/env bats +# Timeout functionality tests +# Tests for lib/core/timeout.sh + +setup() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + export PROJECT_ROOT + export MO_DEBUG=0 # Disable debug output for cleaner tests +} + +# ================================================================= +# Basic Timeout Functionality +# ================================================================= + +@test "run_with_timeout: command completes before timeout" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 echo 'success' + ") + [[ "$result" == "success" ]] +} + +@test "run_with_timeout: zero timeout runs command normally" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 0 echo 'no_timeout' + ") + [[ "$result" == "no_timeout" ]] +} + +@test "run_with_timeout: invalid timeout runs command normally" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout invalid echo 'no_timeout' + ") + [[ "$result" == "no_timeout" ]] +} + +@test "run_with_timeout: negative timeout runs command normally" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout -5 echo 'no_timeout' + ") + [[ "$result" == "no_timeout" ]] +} + +# ================================================================= +# Exit Code Handling +# ================================================================= + +@test "run_with_timeout: preserves command exit code on success" { + bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 true + " + exit_code=$? + [[ $exit_code -eq 0 ]] +} + +@test "run_with_timeout: preserves command exit code on failure" { + set +e + bash -c " + set +e # Don't exit on error + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 false + exit \$? + " + exit_code=$? + set -e + [[ $exit_code -eq 1 ]] +} + +@test "run_with_timeout: returns 124 on timeout (if using gtimeout)" { + # This test only passes if gtimeout/timeout is available + # Skip if using shell fallback (can't guarantee exit code 124 in all cases) + if ! command -v gtimeout >/dev/null 2>&1 && ! command -v timeout >/dev/null 2>&1; then + skip "gtimeout/timeout not available" + fi + + set +e + bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 1 sleep 10 + exit \$? + " + exit_code=$? + set -e + [[ $exit_code -eq 124 ]] +} + +# ================================================================= +# Timeout Behavior +# ================================================================= + +@test "run_with_timeout: kills long-running command" { + # Command should be killed after 2 seconds + start_time=$(date +%s) + set +e + bash -c " + set +e + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 2 sleep 30 + " >/dev/null 2>&1 + set -e + end_time=$(date +%s) + duration=$((end_time - start_time)) + + # Should complete in ~2 seconds, not 30 + # Allow some margin (up to 5 seconds for slow systems) + [[ $duration -lt 10 ]] +} + +@test "run_with_timeout: handles fast-completing commands" { + # Fast command should complete immediately + start_time=$(date +%s) + bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 10 echo 'fast' + " >/dev/null 2>&1 + end_time=$(date +%s) + duration=$((end_time - start_time)) + + # Should complete in ~0 seconds + [[ $duration -lt 3 ]] +} + +# ================================================================= +# Pipefail Compatibility +# ================================================================= + +@test "run_with_timeout: works in pipefail mode" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 echo 'pipefail_test' + ") + [[ "$result" == "pipefail_test" ]] +} + +@test "run_with_timeout: doesn't cause unintended exits" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 true || true + echo 'survived' + ") + [[ "$result" == "survived" ]] +} + +# ================================================================= +# Command Arguments +# ================================================================= + +@test "run_with_timeout: handles commands with arguments" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 echo 'arg1' 'arg2' 'arg3' + ") + [[ "$result" == "arg1 arg2 arg3" ]] +} + +@test "run_with_timeout: handles commands with spaces in arguments" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 echo 'hello world' + ") + [[ "$result" == "hello world" ]] +} + +# ================================================================= +# Debug Logging +# ================================================================= + +@test "run_with_timeout: debug logging when MO_DEBUG=1" { + output=$(bash -c " + set -euo pipefail + export MO_DEBUG=1 + source '$PROJECT_ROOT/lib/core/timeout.sh' + run_with_timeout 5 echo 'test' 2>&1 + ") + # Should contain debug output + [[ "$output" =~ TIMEOUT ]] +} + +@test "run_with_timeout: no debug logging when MO_DEBUG=0" { + # When MO_DEBUG=0, no debug messages should appear during function execution + # (Initialization messages may appear if module is loaded for first time) + output=$(bash -c " + set -euo pipefail + export MO_DEBUG=0 + unset MO_TIMEOUT_INITIALIZED # Force re-initialization + source '$PROJECT_ROOT/lib/core/timeout.sh' + # Capture only the function call output, not initialization + run_with_timeout 5 echo 'test' + " 2>/dev/null) # Discard stderr (initialization messages) + # Should only have command output + [[ "$output" == "test" ]] +} + +# ================================================================= +# Module Loading +# ================================================================= + +@test "timeout.sh: prevents multiple sourcing" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + source '$PROJECT_ROOT/lib/core/timeout.sh' + echo 'loaded' + ") + [[ "$result" == "loaded" ]] +} + +@test "timeout.sh: sets MOLE_TIMEOUT_LOADED flag" { + result=$(bash -c " + set -euo pipefail + source '$PROJECT_ROOT/lib/core/timeout.sh' + echo \"\$MOLE_TIMEOUT_LOADED\" + ") + [[ "$result" == "1" ]] +} diff --git a/tests/uninstall.bats b/tests/uninstall.bats index 39a2be0..8b2b7be 100644 --- a/tests/uninstall.bats +++ b/tests/uninstall.bats @@ -4,7 +4,10 @@ setup_file() { PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" export PROJECT_ROOT - ORIGINAL_HOME="${HOME:-}" + ORIGINAL_HOME="${BATS_TMPDIR:-}" # Use BATS_TMPDIR as original HOME if set by bats + if [[ -z "$ORIGINAL_HOME" ]]; then + ORIGINAL_HOME="${HOME:-}" + fi export ORIGINAL_HOME HOME="$(mktemp -d "${BATS_TEST_DIRNAME}/tmp-uninstall-home.XXXXXX")" @@ -63,7 +66,9 @@ EOF HOME="$HOME" bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" -files="$(printf '%s\n%s\n' "$HOME/sized/file1" "$HOME/sized/file2")" +files="$(printf '%s +%s +' "$HOME/sized/file1" "$HOME/sized/file2")" calculate_total_size "$files" EOF )" @@ -91,10 +96,10 @@ show_cursor() { :; } remove_apps_from_dock() { :; } pgrep() { return 1; } pkill() { return 0; } -sudo() { return 0; } +sudo() { return 0; } # Mock sudo command app_bundle="$HOME/Applications/TestApp.app" -mkdir -p "$app_bundle" +mkdir -p "$app_bundle" # Ensure this is created in the temp HOME related="$(find_app_files "com.example.TestApp" "TestApp")" encoded_related=$(printf '%s' "$related" | base64 | tr -d '\n') @@ -105,8 +110,10 @@ files_cleaned=0 total_items=0 total_size_cleaned=0 -printf '\n' | batch_uninstall_applications >/dev/null +# Use the actual bash function directly, don't pipe printf as that complicates stdin +batch_uninstall_applications +# Verify cleanup [[ ! -d "$app_bundle" ]] || exit 1 [[ ! -d "$HOME/Library/Application Support/TestApp" ]] || exit 1 [[ ! -d "$HOME/Library/Caches/TestApp" ]] || exit 1 @@ -116,6 +123,21 @@ EOF [ "$status" -eq 0 ] } +@test "safe_remove can remove a simple directory" { + mkdir -p "$HOME/test_dir" + touch "$HOME/test_dir/file.txt" + + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" + +safe_remove "$HOME/test_dir" +[[ ! -d "$HOME/test_dir" ]] || exit 1 +EOF + [ "$status" -eq 0 ] +} + + @test "decode_file_list validates base64 encoding" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc << 'EOF' set -euo pipefail @@ -123,7 +145,8 @@ source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/uninstall/batch.sh" # Valid base64 encoded path list -valid_data=$(printf '/path/one\n/path/two' | base64) +valid_data=$(printf '/path/one +/path/two' | base64) result=$(decode_file_list "$valid_data" "TestApp") [[ -n "$result" ]] || exit 1 EOF @@ -184,4 +207,4 @@ fi EOF [ "$status" -eq 0 ] -} +} \ No newline at end of file