scripts/test.sh
Rama main · réplica de lectura del repositorio original.
#!/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/.*<testsuite name="\([^"]*\)".*time="\([0-9.]*\)".*/\2 \1/p' "$report" |
sort -rn | head -n 10 |
awk '{ printf " %8.1f %s\n", $1, $2 }'
}
report_unit_result() {
if [[ $1 -eq 0 ]]; then
printf '%b' "${GREEN}${ICON_SUCCESS} Unit tests passed${NC}\n"
else
printf '%b' "${RED}${ICON_ERROR} Unit tests failed${NC}\n"
FAILED=$((FAILED + 1))
fi
}
enforce_timeout_dependency_in_ci
GO_TEST_CACHE="${MOLE_GO_TEST_CACHE:-/tmp/mole-go-build-cache}"
export MOLE_GO_TEST_CACHE="$GO_TEST_CACHE"
test_selection_needs_go_helpers() {
local test_file
for test_file in "$@"; do
case "$test_file" in
tests | ./tests | */tests | tests/cli.bats | ./tests/cli.bats | */tests/cli.bats)
return 0
;;
esac
done
return 1
}
prepare_go_test_helpers() {
command -v go > /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 <testsuite name= time=> 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