spawn/test/macos-compat.sh
Ahmed Abushagur 22b6a402f4
feat: E2E test harness, QA pipeline integration, macOS compat linter (#1425)
* feat: add QA upgrade — macOS compat linter, per-agent mock assertions

Layer 1: macOS compat linter (test/macos-compat.sh)
- 12 rules (MC001–MC012) catching bash 3.2 incompatibilities
- Detects: base64 -w0 file args, non-portable echo flags, source <(),
  ((var++)), read -d, nounset flag, sed -i, date %N, local -n,
  declare -A, ${var,,}, and |&
- Added to CI lint.yml in warn-only mode for burn-in
- Integrated as Phase 0.5 in qa-dry-run.sh

Layer 2: Per-agent mock assertions
- test/fixtures/_shared_agent_assertions.sh with install checks
  for all 15 agents (claude, openclaw, aider, goose, etc.)
- Integrated into test/mock.sh via _run_agent_assertions()

Also includes branch fixes:
- Fix base64 -w0 to use stdin redirect (aws, daytona, fly)
- Fix fly/openclaw to use npm install instead of broken curl|bash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E test harness and integrate into QA pipeline

Add test/e2e.sh — a full E2E test harness that provisions real servers,
installs agents, and verifies setup across all clouds. Features:
- Smoke test (one canary agent per cloud) and full matrix modes
- Credential auto-detection for 8 clouds
- Per-cloud preflight validation (sequential) then parallel agent tests
- Stale server cleanup, timing history, cross-cloud comparison
- Auto-fix and optimization phases via Claude agents
- macOS bash 3.2 compatible

Integrate E2E as Phase 5 in both qa-cycle.sh and qa-dry-run.sh:
- Runs after mock tests pass, gated on cloud credentials
- Phase 5b auto-fixes failures using per-agent worktree branches
- Parses results and includes in QA summary

Also fixes:
- shared/common.sh: honour SPAWN_NON_INTERACTIVE=1 in safe_read()
- aws/lib/common.sh: fix SSH key import (use cat instead of base64,
  handle race condition on concurrent imports)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:41:07 -05:00

175 lines
5.9 KiB
Bash
Executable file

#!/bin/bash
set -eo pipefail
# macOS Compatibility Linter
# Catches bash 3.2 incompatibilities in shell scripts.
# This script itself is bash 3.2 compatible.
#
# Usage:
# bash test/macos-compat.sh # Scan all .sh files
# bash test/macos-compat.sh --warn-only # Always exit 0
# bash test/macos-compat.sh path/to/file.sh # Scan specific file(s)
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WARN_ONLY=false
FILES_CHECKED=0
# Parse arguments
TARGETS=""
while [ $# -gt 0 ]; do
case "$1" in
--warn-only)
WARN_ONLY=true
shift
;;
*)
TARGETS="$TARGETS $1"
shift
;;
esac
done
TARGETS="$(printf '%s' "$TARGETS" | sed 's/^ //')"
# Check if a path should be excluded
is_excluded() {
case "$1" in
*/.claude/skills/*) return 0 ;;
*/.git/*) return 0 ;;
*/node_modules/*) return 0 ;;
*/cli/*) return 0 ;;
*/test/macos-compat.sh) return 0 ;; # Don't lint ourselves
*) return 1 ;;
esac
}
# Collect .sh files to check
collect_files() {
if [ -n "$TARGETS" ]; then
for _cf_target in $TARGETS; do
if [ -d "$_cf_target" ]; then
find "$_cf_target" -name '*.sh' -type f
elif [ -f "$_cf_target" ]; then
printf '%s\n' "$_cf_target"
fi
done
else
find "$REPO_ROOT" -name '*.sh' -type f
fi
}
# Temp file for all findings
_findings="$(mktemp)"
trap 'rm -f "$_findings"' EXIT
# grep_rule: fast grep-based rule check, appends findings to $_findings
# Filters out comment lines (leading whitespace + #)
# Args: severity rule_id message file relpath pattern
grep_rule() {
local sev="$1" rule="$2" msg="$3" file="$4" rel="$5" pattern="$6"
grep -nE "$pattern" "$file" 2>/dev/null | while IFS=: read -r lnum content; do
# Skip comment lines
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in
'#'*) continue ;;
esac
printf '%s %s:%s %s %s\n' "$sev" "$rel" "$lnum" "$rule" "$msg"
done >> "$_findings" || true
}
# Collect all files
_all_files="$(collect_files | sort)"
# Process each file
while IFS= read -r _f; do
[ -z "$_f" ] && continue
is_excluded "$_f" && continue
FILES_CHECKED=$((FILES_CHECKED + 1))
_r="$(printf '%s' "$_f" | sed "s|^${REPO_ROOT}/||")"
# MC001: base64 -w0 with file arg (not stdin redirect) — two-pass
grep -nE 'base64.*-w0[[:space:]]+["$]' "$_f" 2>/dev/null | while IFS=: read -r lnum content; do
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
printf '%s' "$content" | grep -q '<[[:space:]]' && continue
printf 'error %s:%s MC001 %s\n' "$_r" "$lnum" \
"'base64 -w0 \$file' (GNU-only) — use 'base64 -w0 < \$file' instead"
done >> "$_findings" || true
# MC002: non-portable echo flags
_mc002_msg="'echo"
_mc002_msg="${_mc002_msg} -e' is not portable — use printf instead"
grep_rule "error" "MC002" "$_mc002_msg" \
"$_f" "$_r" 'echo[[:space:]]+-[en]+[[:space:]]'
# MC003: source <(...) or . <(...)
grep_rule "error" "MC003" "'source <(...)' fails in bash <(curl...) — use eval instead" \
"$_f" "$_r" '(source|\.)[[:space:]]+<\('
# MC004: ((var++)) or ((var--)) — post-increment
grep_rule "error" "MC004" "'((var++))' can fail with set -e — use var=\$((var + 1))" \
"$_f" "$_r" '\(\([[:space:]]*[a-zA-Z_]+[[:space:]]*(\+\+|--)[[:space:]]*\)\)'
# MC004: ((++var)) or ((--var)) — pre-increment
grep_rule "error" "MC004" "'((++var))' can fail with set -e — use var=\$((var + 1))" \
"$_f" "$_r" '\(\([[:space:]]*(\+\+|--)[[:space:]]*[a-zA-Z_]+[[:space:]]*\)\)'
# MC005: read -d
grep_rule "error" "MC005" "'read -d' requires bash 4+ — use alternative approach" \
"$_f" "$_r" 'read[[:space:]].*-d'
# MC006: nounset flag (set -u and variants)
grep_rule "error" "MC006" "'set -u' (nounset) — use \${VAR:-} instead" \
"$_f" "$_r" 'set[[:space:]]+-[a-zA-Z]*u'
# MC007: sed -i without '' (warn only) — two-pass
grep -nE "sed[[:space:]]+-i[[:space:]]" "$_f" 2>/dev/null | while IFS=: read -r lnum content; do
case "$(printf '%s' "$content" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
printf '%s' "$content" | grep -qE "sed[[:space:]]+-i[[:space:]]+''" && continue
printf "warn %s:%s MC007 'sed -i' without '' may fail on macOS\n" "$_r" "$lnum"
done >> "$_findings" || true
# MC008: date %N (nanoseconds)
grep_rule "error" "MC008" "'date %N' (nanoseconds) not available on macOS" \
"$_f" "$_r" 'date[[:space:]][^|;]*%N'
# MC009: local -n / declare -n (namerefs)
grep_rule "error" "MC009" "'local -n' (namerefs) requires bash 4.3+" \
"$_f" "$_r" '(local|declare)[[:space:]]+-n[[:space:]]'
# MC010: declare -A (associative arrays)
grep_rule "error" "MC010" "'declare -A' (associative arrays) requires bash 4.0+" \
"$_f" "$_r" 'declare[[:space:]]+-A[[:space:]]'
# MC011: ${var,,} or ${var^^} (case modification)
grep_rule "error" "MC011" "'\${var,,}'/'\${var^^}' (case modification) requires bash 4.0+" \
"$_f" "$_r" '\$\{[a-zA-Z_][a-zA-Z0-9_]*(,,|\^\^)'
# MC012: |& (pipe stderr)
grep_rule "error" "MC012" "'|&' (pipe stderr) requires bash 4.0+ — use 2>&1 | instead" \
"$_f" "$_r" '\|&[^&]'
done <<FILELIST
$_all_files
FILELIST
# Output findings
if [ -s "$_findings" ]; then
cat "$_findings"
fi
# Count errors and warnings from the findings file
ERRORS=$(grep -c '^error ' "$_findings" 2>/dev/null || true)
WARNINGS=$(grep -c '^warn ' "$_findings" 2>/dev/null || true)
# Summary
printf '\nmacOS compat: %d error(s), %d warning(s) in %d file(s)\n' "$ERRORS" "$WARNINGS" "$FILES_CHECKED"
# Exit code
if [ "$WARN_ONLY" = true ]; then
exit 0
fi
if [ "$ERRORS" -gt 0 ]; then
exit 1
fi
exit 0