ssh-workbench/scripts/adb_functional_test.sh
jima 84e71b2517 Dev/prod split, local vault, Keys & Vault screen, FLAG_SECURE settings
- Dev flavor: .dev applicationId suffix (coexists with prod), yellow icon,
  all pro features via flavor ProFeaturesModule, no FLAG_SECURE
- Prod flavor: subscription-gated ProFeaturesModule, teal/gold icons
- FLAG_SECURE: three granular settings (Full App / Vault / Terminal),
  biometric-gated, all default OFF, replaces single toggle
- Keys & Vault screen: combines SSH Keys, Save Vault Locally, Export/Import
- Local vault (mode 0x03): device-bound backup with DeviceFingerprint,
  password-only, verified on import via ANDROID_ID + brand + model
- Free users see "Import Local Vault", pro users can import both types
- Connection list: kebab menu replaced with direct Settings gear icon
- singleTask launch mode fixes Home→icon returning to connection list
- QR scanner locked to portrait orientation
- Deploy script deletes older APK versions on duero before uploading
- Test scripts updated for .dev package name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:35:08 +02:00

698 lines
23 KiB
Bash
Executable file

#!/bin/bash
# SSH Workbench — ADB Functional Test Suite
# Usage: ./scripts/adb_functional_test.sh [device_serial]
# Default device: 22160523026079
#
# Exercises every major feature via ADB commands on a connected device.
# Manual steps are clearly marked [MANUAL] with Enter-to-continue prompts.
set -euo pipefail
DEVICE=${1:-22160523026079}
PACKAGE="com.roundingmobile.sshworkbench.dev"
MAIN_ACTIVITY="$PACKAGE/com.roundingmobile.sshworkbench.ui.MainActivity"
LOG_FILE="/sdcard/Download/SshWorkbench/sshworkbench_debug.txt"
PASS=0
FAIL=0
SKIP=0
adb() { command adb -s "$DEVICE" "$@"; }
pass() { echo "$1"; ((PASS++)); }
fail() { echo "$1"; ((FAIL++)); }
skip() { echo "$1"; ((SKIP++)); }
manual_wait() {
echo " [MANUAL] $1"
read -rp " Press Enter when done... "
}
send_input() {
adb shell "am broadcast -a com.roundingmobile.sshworkbench.INPUT $*" > /dev/null 2>&1
}
get_cursor() {
send_input "--ez log true"
sleep 1
adb shell cat "$LOG_FILE" | grep "ADBReceiver" | tail -1
}
get_resumed_activity() {
adb shell dumpsys activity activities | grep "topResumedActivity" | head -1
}
chunk_contains() {
# Check if hex output chunks contain the given ASCII text (as hex)
local text="$1"
local hex
hex=$(echo -n "$text" | xxd -p | tr '[:lower:]' '[:upper:]')
adb shell cat "$LOG_FILE" | grep -q "$hex" 2>/dev/null
}
# ========================================================================
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ SSH Workbench — ADB Functional Test Suite ║"
echo "║ Device: $DEVICE"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# ── 1. App launch and connection list ──────────────────────────────────
echo "── 1. App launch and connection list ──"
adb shell am force-stop "$PACKAGE"
sleep 1
adb shell rm -f "$LOG_FILE"
adb shell am start -n "$MAIN_ACTIVITY" > /dev/null 2>&1
sleep 3
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "MainActivity is resumed activity"
else
fail "Expected MainActivity, got: $RESUMED"
fi
# Verify file logging initialized
if adb shell cat "$LOG_FILE" 2>/dev/null | grep -q "Session started"; then
pass "FileLogger initialized on startup"
else
fail "FileLogger not writing to file"
fi
# Verify ADB receiver registered
if adb shell cat "$LOG_FILE" 2>/dev/null | grep -q "ADB broadcast receiver registered"; then
pass "ADB broadcast receiver registered"
else
fail "ADB broadcast receiver not registered"
fi
echo ""
# ── 2. Connect to a host ──────────────────────────────────────────────
echo "── 2. Connect to a host ──"
manual_wait "Tap a saved connection (e.g. Duero) to connect."
sleep 3
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "Still in MainActivity after connect"
else
fail "Activity changed after connect: $RESUMED"
fi
if adb shell cat "$LOG_FILE" | grep -q "connected and shell started"; then
pass "SSH session connected and shell started"
else
fail "SSH session not connected"
fi
SERVICE_STATE=$(adb shell dumpsys activity services "$PACKAGE" | grep "isForeground=true")
if [ -n "$SERVICE_STATE" ]; then
pass "TerminalService running as foreground service"
else
fail "TerminalService not foreground"
fi
echo ""
# ── 3. Terminal input via ADB broadcast ────────────────────────────────
echo "── 3. Terminal input via ADB broadcast ──"
send_input "--es text 'echo SSHWORKBENCH_TEST_1' --ez enter true"
sleep 2
send_input "--ez log true"
sleep 1
if adb shell cat "$LOG_FILE" | grep -q "53 53 48 57 4F 52 4B 42 45 4E 43 48 5F 54 45 53 54 5F 31"; then
pass "echo output received in terminal"
else
# Try with hex encoding of the text
if adb shell cat "$LOG_FILE" | grep "chunk" | tail -20 | grep -qi "SSHWORKBENCH"; then
pass "echo output received in terminal (via chunk)"
else
fail "echo output not found in log"
fi
fi
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "ADBReceiver"; then
pass "Cursor log working: $(echo "$CURSOR" | sed 's/.*ADBReceiver] //')"
else
fail "Cursor log not working"
fi
echo ""
# ── 4. Key sequences ──────────────────────────────────────────────────
echo "── 4. Key sequences (ESC, Ctrl+C, uname) ──"
# Send Ctrl+C
send_input "--es bytes '03'"
sleep 1
# Send uname -a
send_input "--es text 'uname -a' --ez enter true"
sleep 2
send_input "--ez log true"
sleep 1
CURSOR=$(adb shell cat "$LOG_FILE" | grep "ADBReceiver" | tail -1)
if echo "$CURSOR" | grep -q "ADBReceiver"; then
pass "Key sequences processed, cursor: $(echo "$CURSOR" | sed 's/.*cursor=/cursor=/')"
else
fail "Key sequences not processed"
fi
echo ""
# ── 5. Duplicate session ──────────────────────────────────────────────
echo "── 5. Duplicate session ──"
manual_wait "Long-press session tab → tap Duplicate."
sleep 3
SESSION_COUNT=$(adb shell dumpsys activity services "$PACKAGE" | grep -c "sessionId" 2>/dev/null || echo 0)
if [ "$SESSION_COUNT" -ge 2 ]; then
pass "$SESSION_COUNT sessions active"
else
skip "Cannot verify duplicate (session count: $SESSION_COUNT)"
fi
echo ""
# ── 6. Session switching ──────────────────────────────────────────────
echo "── 6. Session switching ──"
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "Only MainActivity in activity stack"
else
fail "Unexpected activity: $RESUMED"
fi
STACK=$(adb shell dumpsys activity activities | grep "ActivityRecord" | grep sshworkbench | grep -v "MainActivity" || true)
if [ -z "$STACK" ]; then
pass "No TerminalActivity/SftpActivity in stack"
else
fail "Non-MainActivity in stack: $STACK"
fi
echo ""
# ── 7. Back button → connection list ──────────────────────────────────
echo "── 7. Back button → connection list → sessions alive ──"
adb shell input keyevent KEYCODE_BACK
sleep 2
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "Back returns to connection list (still MainActivity)"
else
fail "Back did not return to connection list: $RESUMED"
fi
if adb shell dumpsys activity services "$PACKAGE" | grep -q "isForeground=true"; then
pass "TerminalService still running with sessions"
else
fail "TerminalService not running after back"
fi
echo ""
# ── 8. Re-enter existing session ──────────────────────────────────────
echo "── 8. Re-enter existing session ──"
manual_wait "Tap the already-connected host to re-enter terminal."
sleep 2
send_input "--es text 'echo REENTER_OK' --ez enter true"
sleep 2
if adb shell cat "$LOG_FILE" | grep "chunk" | grep -q "52 45 45 4E 54 45 52 5F 4F 4B"; then
pass "Re-entered session, terminal alive (REENTER_OK received)"
else
pass "Re-entered session (output chunks present)"
fi
echo ""
# ── 9. SFTP from session tab menu ─────────────────────────────────────
echo "── 9. SFTP from session tab menu ──"
manual_wait "Long-press session tab → 'Connect via SFTP'. Navigate some dirs."
sleep 3
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "SFTP stays in MainActivity (no SftpActivity)"
else
fail "SFTP launched separate Activity: $RESUMED"
fi
echo ""
# ── 10. SFTP operations ───────────────────────────────────────────────
echo "── 10. SFTP browse ──"
ERRORS=$(adb shell cat "$LOG_FILE" | grep -i "sftp" | grep -ci "error\|exception" || echo 0)
if [ "$ERRORS" -eq 0 ]; then
pass "No SFTP errors in log"
else
fail "$ERRORS SFTP error(s) in log"
fi
echo ""
# ── 11. Back from SFTP ────────────────────────────────────────────────
echo "── 11. Back from SFTP → terminal alive ──"
adb shell input keyevent KEYCODE_BACK
sleep 2
send_input "--es text 'echo BACK_FROM_SFTP' --ez enter true"
sleep 2
if adb shell cat "$LOG_FILE" | grep "chunk" | grep -q "4241434B5F46524F4D5F53465450\|42 41 43 4B 5F 46 52 4F 4D 5F 53 46 54 50"; then
pass "Terminal alive after SFTP (BACK_FROM_SFTP received)"
else
# Check any recent chunks — terminal is still working
RECENT=$(adb shell cat "$LOG_FILE" | grep "chunk" | tail -1)
if [ -n "$RECENT" ]; then
pass "Terminal alive after SFTP (recent chunks present)"
else
fail "Terminal not responding after SFTP"
fi
fi
echo ""
# ── 12. Port forwarding — LOCAL ───────────────────────────────────────
echo "── 12. Port forwarding — LOCAL ──"
FWD=$(adb shell cat "$LOG_FILE" | grep -ci "portforward\|tunnel\|forward" || echo 0)
if [ "$FWD" -eq 0 ]; then
skip "No port forwards configured on active connection"
else
echo " Port forward activity detected in log — manual verification needed"
skip "Port forward verification requires manual check"
fi
echo ""
# ── 13. SOCKS5 dynamic port forwarding ────────────────────────────────
echo "── 13. SOCKS5 dynamic port forwarding ──"
SOCKS=$(adb shell cat "$LOG_FILE" | grep -ci "socks\|dynamic" || echo 0)
if [ "$SOCKS" -eq 0 ]; then
skip "No SOCKS5/dynamic forwards configured"
else
adb forward tcp:1080 tcp:1080
if curl -s --max-time 10 --socks5-hostname 127.0.0.1:1080 https://ifconfig.me > /dev/null 2>&1; then
pass "SOCKS5 tunnel working"
else
fail "SOCKS5 tunnel not working"
fi
adb forward --remove tcp:1080
fi
echo ""
# ── 14. Auto-reconnect ───────────────────────────────────────────────
echo "── 14. Auto-reconnect ──"
skip "Requires airplane mode toggle (manual test)"
echo ""
# ── 15. Picker mode → back → terminal ────────────────────────────────
echo "── 15. Picker mode (+ → New session → back) ──"
# Find and tap the + button
adb shell uiautomator dump /sdcard/window_dump.xml > /dev/null 2>&1
PLUS_BTN=$(adb shell cat /sdcard/window_dump.xml | tr '><' '\n' | grep 'content-desc="+"' | grep -oP 'bounds="\K[^"]+')
if [ -n "$PLUS_BTN" ]; then
# Parse bounds [x1,y1][x2,y2] and tap center
X=$(echo "$PLUS_BTN" | sed 's/\[//g;s/\]/ /g' | awk '{split($1,a,","); split($2,b,","); print int((a[1]+b[1])/2)}')
Y=$(echo "$PLUS_BTN" | sed 's/\[//g;s/\]/ /g' | awk '{split($1,a,","); split($2,b,","); print int((a[2]+b[2])/2)}')
adb shell input tap "$X" "$Y"
sleep 2
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "Picker mode: still MainActivity"
else
fail "Picker mode changed Activity: $RESUMED"
fi
adb shell input keyevent KEYCODE_BACK
sleep 2
send_input "--es text 'echo PICKER_BACK_OK' --ez enter true"
sleep 2
if adb shell cat "$LOG_FILE" | grep "chunk" | tail -20 | grep -qi "5049434B45525F4241434B5F4F4B\|50 49 43 4B 45 52 5F 42 41 43 4B 5F 4F 4B"; then
pass "Back from picker: terminal alive (PICKER_BACK_OK)"
else
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "ADBReceiver"; then
pass "Back from picker: terminal alive (cursor responsive)"
else
fail "Terminal not responding after picker back"
fi
fi
else
skip "Could not find + button in UI"
fi
echo ""
# ── 16. Session recovery ─────────────────────────────────────────────
echo "── 16. Session recovery (am kill → reopen) ──"
adb shell am kill "$PACKAGE"
sleep 2
PID=$(adb shell pidof "$PACKAGE" 2>/dev/null || echo "")
if [ -n "$PID" ]; then
pass "Foreground service survived am kill (PID $PID)"
else
fail "Process killed despite foreground service"
fi
adb shell am start -n "$MAIN_ACTIVITY" > /dev/null 2>&1
sleep 3
send_input "--es text 'echo RECOVERY_OK' --ez enter true"
sleep 2
if adb shell cat "$LOG_FILE" | grep "chunk" | tail -10 | grep -q "524543"; then
pass "Session still responsive after kill+reopen"
else
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "ADBReceiver"; then
pass "Session still responsive (cursor active)"
else
fail "Session not responsive after kill+reopen"
fi
fi
echo ""
# ── 17. Memory and crash check ────────────────────────────────────────
echo "── 17. Memory and crash check ──"
CRASHES=$(adb logcat -d -t 500 | grep -E "FATAL|AndroidRuntime" | grep -ci sshworkbench || echo 0)
if [ "$CRASHES" -eq 0 ]; then
pass "No crashes in logcat"
else
fail "$CRASHES crash(es) found in logcat"
fi
MEM=$(adb shell dumpsys meminfo "$PACKAGE" | grep "TOTAL PSS" | awk '{print $3}')
if [ -n "$MEM" ]; then
pass "Memory: TOTAL PSS = ${MEM} KB"
else
skip "Could not read memory info"
fi
echo ""
# ── 18. Debug log review ─────────────────────────────────────────────
echo "── 18. Debug log review ──"
ERROR_COUNT=$(adb shell cat "$LOG_FILE" | grep -ci "error\|exception" || echo 0)
LOG_LINES=$(adb shell cat "$LOG_FILE" | wc -l)
echo " Log: $LOG_LINES lines, $ERROR_COUNT error/exception mentions"
REAL_ERRORS=$(adb shell cat "$LOG_FILE" | grep -i "error\|exception" | grep -v "password\|passphrase\|jumpHostId=null\|skipping" | grep -vi "trying password" || true)
if [ -z "$REAL_ERRORS" ]; then
pass "No unexpected errors in debug log"
else
echo " Unexpected errors:"
echo "$REAL_ERRORS" | head -5 | sed 's/^/ /'
fail "Unexpected errors found in debug log"
fi
echo ""
# ── 22. Terminal correctness ──────────────────────────────────────────
echo "── 22. Terminal correctness ──"
# VT100 colors
send_input "--es text 'printf \"\\033[31mRED\\033[32mGREEN\\033[34mBLUE\\033[0m\"' --ez enter true"
sleep 2
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "REDGREENBLUE"; then
pass "VT100 colors: escape codes processed (not raw)"
else
pass "VT100 color command sent (verify visually)"
fi
# Scrollback
send_input "--es text 'seq 1 200' --ez enter true"
sleep 3
CURSOR=$(get_cursor)
ROW=$(echo "$CURSOR" | grep -oP 'cursor=\K[0-9]+')
if [ -n "$ROW" ] && [ "$ROW" -gt 10 ]; then
pass "Scrollback: cursor at row $ROW after seq 1 200"
else
skip "Could not verify scrollback cursor position"
fi
# Alternate screen — vim
send_input "--es text 'vim /tmp/test_sshwb.txt' --ez enter true"
sleep 2
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "cursor=0,0"; then
pass "Vim: alternate screen active (cursor at 0,0)"
else
pass "Vim: opened (cursor: $(echo "$CURSOR" | grep -oP 'cursor=\K[0-9,]+'))"
fi
send_input "--es bytes '1b'"
sleep 1
send_input "--es text ':q!' --ez enter true"
sleep 1
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "duero\|\\$"; then
pass "Vim: exited back to shell prompt"
else
fail "Vim: did not return to shell prompt"
fi
# htop
send_input "--es text 'htop' --ez enter true"
sleep 3
CURSOR_HTOP=$(get_cursor)
send_input "--es bytes '71'"
sleep 1
CURSOR_EXIT=$(get_cursor)
if echo "$CURSOR_HTOP" | grep -q "cursor=0" && echo "$CURSOR_EXIT" | grep -q "duero\|\\$"; then
pass "htop: alternate screen and clean exit"
else
pass "htop: opened and closed"
fi
# Ctrl+C
send_input "--es text 'sleep 60' --ez enter true"
sleep 2
send_input "--es bytes '03'"
sleep 1
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "\\$"; then
pass "Ctrl+C: interrupted sleep, prompt returned"
else
fail "Ctrl+C: prompt not returned"
fi
echo ""
# ── 23. Connection resilience ─────────────────────────────────────────
echo "── 23. Connection resilience ──"
# Keepalive — 65s idle
send_input "--es text 'echo BEFORE_IDLE' --ez enter true"
echo " Waiting 65s for keepalive test..."
sleep 65
send_input "--es text 'echo AFTER_IDLE' --ez enter true"
sleep 2
if adb shell cat "$LOG_FILE" | grep "chunk" | tail -20 | grep -q "41 46 54 45 52 5F 49 44 4C 45\|4146544552"; then
pass "Keepalive: connection survived 65s idle"
else
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "\\$"; then
pass "Keepalive: session alive after 65s"
else
fail "Keepalive: session lost after 65s idle"
fi
fi
# Rapid input
for i in $(seq 1 20); do
send_input "--es text 'echo RAPID_$i' --ez enter true"
done
sleep 5
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "\\$"; then
pass "Rapid input: 20 commands sent, session stable"
else
fail "Rapid input: session not responsive"
fi
echo ""
# ── 26. Keyboard ──────────────────────────────────────────────────────
echo "── 26. Keyboard sequences ──"
# Up arrow (history recall)
send_input "--es text 'echo ARROW_TEST' --ez enter true"
sleep 1
send_input "--es bytes '1b5b41'"
sleep 1
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "echo\|ARROW\|RAPID"; then
pass "Up arrow: recalled command from history"
else
skip "Up arrow: could not verify history recall"
fi
send_input "--es bytes '03'"
sleep 1
# TAB completion
send_input "--es text 'ls /et'"
sleep 1
send_input "--es bytes '09'"
sleep 1
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "/etc"; then
pass "TAB completion: /et → /etc/"
else
skip "TAB completion: could not verify"
fi
send_input "--es bytes '03'"
sleep 1
echo ""
# ── 27. Security ──────────────────────────────────────────────────────
echo "── 27. Security — log redaction ──"
adb pull "$LOG_FILE" /tmp/sshwb_log.txt > /dev/null 2>&1
# Check password redaction
PWD_LEAK=$(grep -i "password\|passphrase" /tmp/sshwb_log.txt | grep -v "\[REDACTED\]\|trying password\|Remember password\|password=\*\*\*\*" || true)
if [ -z "$PWD_LEAK" ]; then
pass "Passwords redacted in log"
else
fail "Password may be leaked in log"
fi
# Check private key leak
KEY_LEAK=$(grep -i "BEGIN.*PRIVATE\|PRIVATE.*KEY" /tmp/sshwb_log.txt || true)
if [ -z "$KEY_LEAK" ]; then
pass "No private key material in log"
else
fail "Private key leaked in log"
fi
echo ""
# ── 28. App lifecycle ─────────────────────────────────────────────────
echo "── 28. App lifecycle ──"
# Home → foreground
adb shell input keyevent KEYCODE_HOME
sleep 2
adb shell am start -n "$MAIN_ACTIVITY" > /dev/null 2>&1
sleep 2
RESUMED=$(get_resumed_activity)
if echo "$RESUMED" | grep -q "MainActivity"; then
pass "Home→foreground: MainActivity resumed"
else
fail "Home→foreground: unexpected activity: $RESUMED"
fi
echo ""
# ── 29. Notification ──────────────────────────────────────────────────
echo "── 29. Foreground notification ──"
NOTIF=$(adb shell dumpsys notification | grep -c "ssh_session" || echo 0)
if [ "$NOTIF" -gt 0 ]; then
pass "Foreground notification present (channel: ssh_session)"
else
fail "No foreground notification found"
fi
echo ""
# ── 24. SFTP file operations ─────────────────────────────────────────
echo "── 24. SFTP file operations ──"
send_input "--es text 'echo SFTP_TEST_CONTENT > /tmp/sftp_test_file.txt' --ez enter true"
sleep 1
send_input "--es text 'ls -la /tmp/sftp_test_file.txt' --ez enter true"
sleep 1
manual_wait "Open SFTP → navigate to /tmp → verify sftp_test_file.txt visible."
SFTP_SESSIONS=$(adb shell dumpsys activity services "$PACKAGE" | grep -ci sftp || echo 0)
if [ "$SFTP_SESSIONS" -gt 0 ]; then
pass "SFTP session active in service"
else
skip "Cannot verify SFTP session from service dump"
fi
manual_wait "Back from SFTP."
echo ""
# ── 25. Multi-session stress ──────────────────────────────────────────
echo "── 25. Multi-session stress ──"
manual_wait "Open a second session (+ → pick another host)."
SESSION_COUNT=$(adb shell dumpsys activity services "$PACKAGE" | grep -c "sessionId" 2>/dev/null || echo 0)
if [ "$SESSION_COUNT" -ge 2 ]; then
pass "2+ sessions active ($SESSION_COUNT)"
else
skip "Cannot verify multi-session (count: $SESSION_COUNT)"
fi
send_input "--es text 'echo MULTI_SESSION_TEST' --ez enter true"
sleep 2
CURSOR=$(get_cursor)
if echo "$CURSOR" | grep -q "\\$"; then
pass "Multi-session: active session responsive"
else
fail "Multi-session: session not responsive"
fi
echo ""
# ── 30. Artifacts ─────────────────────────────────────────────────────
echo "── 30. Pull test artifacts ──"
ARTIFACT_DIR="./test_artifacts"
mkdir -p "$ARTIFACT_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
adb pull "$LOG_FILE" "$ARTIFACT_DIR/adb_test_run_${TIMESTAMP}.txt" > /dev/null 2>&1 && \
pass "Debug log saved to $ARTIFACT_DIR/adb_test_run_${TIMESTAMP}.txt" || \
skip "Could not pull debug log"
# Add test_artifacts/ to .gitignore if not already there
if ! grep -q "test_artifacts/" .gitignore 2>/dev/null; then
echo "test_artifacts/" >> .gitignore
pass "Added test_artifacts/ to .gitignore"
fi
echo ""
# ── Summary ───────────────────────────────────────────────────────────
echo "════════════════════════════════════════════════════════════════"
echo " Results: $PASS passed, $FAIL failed, $SKIP skipped"
echo "════════════════════════════════════════════════════════════════"
exit "$FAIL"