#!/bin/bash # Test runner for Mole. # Runs unit, Go, and integration tests. # Exits non-zero on failures. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" # Sweep orphaned per-test HOME dirs left behind by killed bats runs. # Normal teardown removes them; this only catches the ones that escaped. # 60-minute threshold avoids racing with a long-running test in progress. if [[ -d "$PROJECT_ROOT/tests" ]]; then find "$PROJECT_ROOT/tests" -maxdepth 1 -type d -name 'tmp-*' -mmin +60 \ -exec rm -rf {} + 2> /dev/null || true # SAFE: confined to tests/tmp-* fi # Never allow the scripted test run to trigger real sudo or Touch ID prompts. export MOLE_TEST_NO_AUTH=1 # Tests assert deterministic ANSI escape output. The suite runs without a # terminal, so lib/core/base.sh would otherwise drop color; the MOLE_TEST_NO_AUTH # export above is what forces it back on. Strip any NO_COLOR the developer has # set in their shell, since that still wins over the force, so the color-escape # assertions match regardless of the host environment. unset NO_COLOR TEST_SYSTEM_STUB_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-test-stubs.XXXXXX")" TEST_SYSTEM_STUB_DIR="$(cd -P "$TEST_SYSTEM_STUB_DIR" && pwd -P)" TEST_GO_HELPER_DIR="" # shellcheck disable=SC2329 # Invoked by trap. cleanup_test_stubs() { rm -rf "$TEST_SYSTEM_STUB_DIR" # SAFE: exact mktemp-created test stub directory if [[ -n "$TEST_GO_HELPER_DIR" ]]; then rm -rf "$TEST_GO_HELPER_DIR" # SAFE: exact mktemp-created Go helper directory fi prune_test_launchservices || true } # Spotlight indexes tests/tmp-* within seconds and registers every fixture # .app with LaunchServices; deleting the fixture leaves the record behind. # Thousands of dead records slow every `lsregister -dump`, which Mole Mac's # uninstall reads (66,532 of them made each read take 36 to 56 s, # 2026-10-26). Unregister only records under tests/ whose bundle is gone. # shellcheck disable=SC2329 # Invoked by cleanup_test_stubs. prune_test_launchservices() { local lsregister="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" [[ -x "$lsregister" ]] || return 0 "$lsregister" -dump Bundle 2> /dev/null | sed -n -E 's/^path: +(.*) \(0x[0-9a-f]+\)$/\1/p' | while IFS= read -r path; do [[ "$path" == "$PROJECT_ROOT/tests/"* ]] || continue [[ -e "$path" || -L "$path" ]] || printf '%s\0' "$path" done | xargs -0 -n 200 "$lsregister" -u > /dev/null 2>&1 } trap cleanup_test_stubs EXIT # Module-loading and installation probes run outside the Bats helper too. # Keep their temp registries and stale-temp pruning inside this owned tree. mkdir -p "$TEST_SYSTEM_STUB_DIR/tmp" export TMPDIR="$TEST_SYSTEM_STUB_DIR/tmp" unset MOLE_RESOLVED_TMPDIR MOLE_TEMP_REGISTRY_FILE cat > "$TEST_SYSTEM_STUB_DIR/sudo" << 'EOF' #!/bin/bash case "${1:-}" in -k) exit 0 ;; -n) exit 1 ;; esac printf 'mole test blocked sudo: %s\n' "$*" >&2 exit 1 EOF cat > "$TEST_SYSTEM_STUB_DIR/osascript" << 'EOF' #!/bin/bash printf 'mole test blocked osascript: %s\n' "$*" >&2 exit 1 EOF cat > "$TEST_SYSTEM_STUB_DIR/launchctl" << 'EOF' #!/bin/bash printf 'mole test blocked launchctl: %s\n' "$*" >&2 exit 0 EOF chmod +x "$TEST_SYSTEM_STUB_DIR/sudo" "$TEST_SYSTEM_STUB_DIR/osascript" "$TEST_SYSTEM_STUB_DIR/launchctl" export PATH="$TEST_SYSTEM_STUB_DIR:$PATH" # shellcheck source=lib/core/file_ops.sh source "$PROJECT_ROOT/lib/core/file_ops.sh" echo "===============================" echo "Mole Test Runner" echo "===============================" echo "" FAILED=0 enforce_timeout_dependency_in_ci() { if [[ "${CI:-}" != "true" && "${GITHUB_ACTIONS:-}" != "true" ]]; then return 0 fi if command -v gtimeout > /dev/null 2>&1 || command -v timeout > /dev/null 2>&1; then return 0 fi printf '%b' "${RED}${ICON_ERROR} Missing timeout binary (gtimeout/timeout) in CI${NC}\n" printf '%b' "${YELLOW}${ICON_WARNING} Install coreutils to provide gtimeout${NC}\n" exit 1 } # Print the slowest test files from the JUnit report written during the run. # Attribution is per file, which is what a parallel run hides: wall clock is # set by the single slowest file, not by the total. report_slowest_test_files() { local report="${MOLE_TEST_REPORT_DIR:-}/report.xml" [[ -n "${MOLE_TEST_REPORT_DIR:-}" && -f "$report" ]] || return 0 printf "\n%s\n" "Slowest test files (seconds):" sed -n 's/.* /dev/null 2>&1 || return 0 TEST_GO_HELPER_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mole-go-helpers.XXXXXX")" mkdir -p "$GO_TEST_CACHE" if GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/analyze-go" ./cmd/analyze > /dev/null 2>&1 && GOCACHE="$GO_TEST_CACHE" go build -o "$TEST_GO_HELPER_DIR/status-go" ./cmd/status > /dev/null 2>&1; then export MOLE_TEST_ANALYZE_BIN="$TEST_GO_HELPER_DIR/analyze-go" export MOLE_TEST_STATUS_BIN="$TEST_GO_HELPER_DIR/status-go" else rm -rf "$TEST_GO_HELPER_DIR" # SAFE: exact mktemp-created Go helper directory TEST_GO_HELPER_DIR="" fi } echo "1. Linting test scripts..." if command -v shellcheck > /dev/null 2>&1; then # Concurrent Bats runs own tests/tmp-* fixtures, not lint inputs. TEST_FILES=() while IFS= read -r file; do TEST_FILES+=("$file") done < <(find tests -type d -path 'tests/tmp-*' -prune -o -type f \( -name '*.bats' -o -name '*.bash' -o -name '*.sh' \) -print | sort) if [[ ${#TEST_FILES[@]} -gt 0 ]]; then if shellcheck --rcfile "$PROJECT_ROOT/.shellcheckrc" "${TEST_FILES[@]}"; then printf '%b' "${GREEN}${ICON_SUCCESS} Test script lint passed${NC}\n" else printf '%b' "${RED}${ICON_ERROR} Test script lint failed${NC}\n" FAILED=$((FAILED + 1)) fi else printf '%b' "${YELLOW}${ICON_WARNING} No test scripts found, skipping${NC}\n" fi else printf '%b' "${YELLOW}${ICON_WARNING} shellcheck not installed, skipping${NC}\n" fi echo "" echo "2. Running unit tests..." if command -v bats > /dev/null 2>&1 && [ -d "tests" ]; then # bats needs a real terminfo entry; it exits 1 with zero assertions without # one. A non-interactive bash supplies TERM=dumb rather than leaving it # empty, so an emptiness check never fires for the case this guard exists # for, and `make test` reported "Unit tests failed" after running 28 of # 1935 assertions with nothing actually broken. if [[ -z "${TERM:-}" || "${TERM}" == "dumb" ]]; then export TERM="xterm-256color" fi if [[ $# -eq 0 ]]; then fd_available=0 zip_available=0 zip_list_available=0 if command -v fd > /dev/null 2>&1; then fd_available=1 fi if command -v zip > /dev/null 2>&1; then zip_available=1 fi if command -v zipinfo > /dev/null 2>&1 || command -v unzip > /dev/null 2>&1; then zip_list_available=1 fi TEST_FILES=() while IFS= read -r file; do case "$file" in tests/installer_fd.bats) if [[ $fd_available -eq 1 ]]; then TEST_FILES+=("$file") fi ;; tests/installer_zip.bats) if [[ $zip_available -eq 1 && $zip_list_available -eq 1 ]]; then TEST_FILES+=("$file") fi ;; *) TEST_FILES+=("$file") ;; esac done < <(find tests -type f -name '*.bats' | sort) if [[ ${#TEST_FILES[@]} -gt 0 ]]; then set -- "${TEST_FILES[@]}" else set -- tests fi fi if test_selection_needs_go_helpers "$@"; then prepare_go_test_helpers fi use_color=false if [[ -t 1 && "${TERM:-}" != "dumb" ]]; then use_color=true fi bats_help="$(bats --help 2>&1 || true)" bats_has_jobs=false bats_has_formatter=false if grep -q -- "--jobs" <<< "$bats_help"; then bats_has_jobs=true fi if grep -q -- "--formatter" <<< "$bats_help"; then bats_has_formatter=true fi # Enable parallel execution across test files when Bats and its backend support it. # Cap at 6 jobs by default to balance speed vs. system load during CI. bats_opts=() if $bats_has_jobs && { command -v parallel > /dev/null 2>&1 || command -v rush > /dev/null 2>&1; }; then _ncpu="$(sysctl -n hw.logicalcpu 2> /dev/null || nproc 2> /dev/null || echo 4)" if [[ "${MOLE_TEST_JOBS:-}" =~ ^[0-9]+$ && "${MOLE_TEST_JOBS:-0}" -gt 0 ]]; then _jobs="$MOLE_TEST_JOBS" else _jobs="$((_ncpu > 6 ? 6 : (_ncpu < 2 ? 2 : _ncpu)))" fi # --no-parallelize-within-files ensures each test file's tests run # sequentially (they share a $HOME set by setup_file and are not safe # to run concurrently). Parallelism is only across files. if [[ $_jobs -gt 1 ]]; then bats_opts+=("--jobs" "$_jobs" "--no-parallelize-within-files") fi unset _ncpu _jobs fi if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then bats_opts+=("--timing") fi # Per-file timings. Parallel TAP output cannot be attributed back to a file, # so one slow file is invisible until it dominates the whole run. The JUnit # report carries one per file; CI sets this and the # slowest files are printed after the run. if [[ -n "${MOLE_TEST_REPORT_DIR:-}" ]] && $bats_has_formatter; then mkdir -p "$MOLE_TEST_REPORT_DIR" bats_opts+=("--report-formatter" "junit" "--output" "$MOLE_TEST_REPORT_DIR") fi # Some test files include wall-clock timing assertions that are skewed by # CPU contention from parallel test workers. Safety/optimize fixtures also # use 1-2s subprocess budgets that must reach their mocked action first. # Project-cache stalled-root probes have a five-second outer alarm. # The uninstall and main-menu PTY cases depend on one-second # escape-sequence reads and real-terminal signal timing, and the history # load-time case compares wall-clock work. cli.bats, uninstall_review.bats # and history.bats each hold such a case and have gone red on main only # under the parallel batch, then passed unchanged on a serial rerun. # When parallel mode is active, # split them out to run sequentially after the parallel batch completes. _sequential_files=() if [[ ${#bats_opts[@]} -gt 0 ]]; then _all=("$@") _rest=() if [[ ${#_all[@]} -eq 1 && -d "${_all[0]}" ]]; then while IFS= read -r _f; do case "$_f" in *core_performance.bats | *regression.bats | *core_safe_functions.bats | *clean_system_caches.bats | *optimize.bats | *uninstall_menu.bats | *cli.bats | *uninstall_review.bats | *history.bats) _sequential_files+=("$_f") ;; *) _rest+=("$_f") ;; esac done < <(find "${_all[0]}" -type f -name '*.bats' | sort) else for _f in "${_all[@]}"; do case "$_f" in *core_performance.bats | *regression.bats | *core_safe_functions.bats | *clean_system_caches.bats | *optimize.bats | *uninstall_menu.bats | *cli.bats | *uninstall_review.bats | *history.bats) _sequential_files+=("$_f") ;; *) _rest+=("$_f") ;; esac done fi if [[ ${#_rest[@]} -gt 0 ]]; then set -- "${_rest[@]}" else set -- fi unset _all _rest _f fi # Accumulate pass/fail across all bats invocations. _unit_rc=0 # Main run (parallel when bats_opts has --jobs, skipped if no files remain). if [[ $# -gt 0 ]]; then if $bats_has_formatter; then formatter="${BATS_FORMATTER:-pretty}" if [[ "$formatter" == "tap" ]]; then if $use_color; then esc=$'\033' bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" | sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 else bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter tap "$@" || _unit_rc=1 fi else # Pretty format for local development bats ${bats_opts[@]+"${bats_opts[@]}"} --formatter "$formatter" "$@" || _unit_rc=1 fi else if $use_color; then esc=$'\033' bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" | sed -e "s/^ok /${esc}[32mok ${esc}[0m /" \ -e "s/^not ok /${esc}[31mnot ok ${esc}[0m /" || _unit_rc=1 else bats ${bats_opts[@]+"${bats_opts[@]}"} --tap "$@" || _unit_rc=1 fi fi fi # Post-run: timing-sensitive tests run after parallel workers have finished # so CPU contention does not skew wall-clock assertions. for _pf in ${_sequential_files[@]+"${_sequential_files[@]}"}; do if [[ "${MOLE_TEST_TIMING:-0}" == "1" ]]; then bats --timing "$_pf" || _unit_rc=1 else bats "$_pf" || _unit_rc=1 fi done unset _sequential_files _pf report_slowest_test_files report_unit_result "$_unit_rc" else printf '%b' "${YELLOW}${ICON_WARNING} bats not installed or no tests found, skipping${NC}\n" fi echo "" echo "3. Running Go tests..." if command -v go > /dev/null 2>&1; then mkdir -p "$GO_TEST_CACHE" # Kept quiet on success; on failure the output names the failing test, # which CI otherwise never shows (a CI-only Go failure on #1839). go_test_log="$TEST_SYSTEM_STUB_DIR/go-test.log" if GOCACHE="$GO_TEST_CACHE" go build ./... > "$go_test_log" 2>&1 && GOCACHE="$GO_TEST_CACHE" go vet ./... >> "$go_test_log" 2>&1 && GOCACHE="$GO_TEST_CACHE" go test ./... >> "$go_test_log" 2>&1; then printf '%b' "${GREEN}${ICON_SUCCESS} Go tests passed${NC}\n" else cat "$go_test_log" printf '%b' "${RED}${ICON_ERROR} Go tests failed${NC}\n" FAILED=$((FAILED + 1)) fi else printf '%b' "${YELLOW}${ICON_WARNING} Go not installed, skipping Go tests${NC}\n" fi echo "" echo "4. Testing module loading..." if bash -c 'source lib/core/common.sh && echo "OK"' > /dev/null 2>&1; then printf '%b' "${GREEN}${ICON_SUCCESS} Module loading passed${NC}\n" else printf '%b' "${RED}${ICON_ERROR} Module loading failed${NC}\n" FAILED=$((FAILED + 1)) fi echo "" echo "5. Running integration tests..." # Quick syntax check for main scripts if bash -n mole && bash -n bin/clean.sh && bash -n bin/optimize.sh; then printf '%b' "${GREEN}${ICON_SUCCESS} Integration tests passed${NC}\n" else printf '%b' "${RED}${ICON_ERROR} Integration tests failed${NC}\n" FAILED=$((FAILED + 1)) fi echo "" echo "6. Testing installation..." # Installation script is macOS-specific; skip this test on non-macOS platforms if [[ "$(uname -s)" != "Darwin" ]]; then printf '%b' "${YELLOW}${ICON_WARNING} Installation test skipped (non-macOS)${NC}\n" else # Skip if Homebrew mole is installed (install.sh will refuse to overwrite) install_test_home="" install_test_prefix="" if command -v brew > /dev/null 2>&1 && brew list mole &> /dev/null; then printf '%b' "${GREEN}${ICON_SUCCESS} Installation test skipped, Homebrew${NC}\n" else install_test_home="$(mktemp -d "$PROJECT_ROOT/tests/tmp-install-home.XXXXXX" 2> /dev/null || true)" if [[ -z "$install_test_home" ]]; then install_test_home="$PROJECT_ROOT/tests/tmp-install-home" mkdir -p "$install_test_home" fi fi if [[ -z "$install_test_home" ]]; then : else install_test_prefix="$install_test_home/mole-bin" fi if [[ -z "$install_test_home" ]]; then : elif HOME="$install_test_home" \ XDG_CONFIG_HOME="$install_test_home/.config" \ XDG_CACHE_HOME="$install_test_home/.cache" \ MO_NO_OPLOG=1 \ ./install.sh --prefix "$install_test_prefix" > /dev/null 2>&1; then if [[ -f "$install_test_prefix/mole" ]]; then printf '%b' "${GREEN}${ICON_SUCCESS} Installation test passed${NC}\n" else printf '%b' "${RED}${ICON_ERROR} Installation test failed${NC}\n" FAILED=$((FAILED + 1)) fi else printf '%b' "${RED}${ICON_ERROR} Installation test failed${NC}\n" FAILED=$((FAILED + 1)) fi if [[ -n "$install_test_prefix" ]]; then MO_NO_OPLOG=1 safe_remove "$install_test_prefix" true || true fi if [[ -n "$install_test_home" ]]; then MO_NO_OPLOG=1 safe_remove "$install_test_home" true || true fi fi echo "" echo "===============================" if [[ $FAILED -eq 0 ]]; then printf '%b' "${GREEN}${ICON_SUCCESS} All tests passed!${NC}\n" exit 0 fi printf '%b' "${RED}${ICON_ERROR} $FAILED tests failed!${NC}\n" exit 1