Files
sing-android/test-e2e.sh
Sing Dev bfc5b675e3 Add quick rule: long-press connection to route domain to Direct/Proxy/Block
Long-pressing a connection in the Active Connections screen shows a
bottom sheet to quickly add the domain to direct/proxy/block rules.
Extracts the registrable domain from the host (e.g. cdn.example.com
→ example.com) and appends it to the corresponding rule list in
SharedPreferences. Duplicate detection prevents re-adding.
2026-04-03 06:42:13 +08:00

562 lines
16 KiB
Bash
Executable File

#!/bin/bash
# End-to-end tests for Sing VPN on Android emulator/device
# Usage: ./test-e2e.sh [test_name]
# Requires: adb connected to device/emulator with app installed
set -uo pipefail
PKG="com.sing.vpn"
CLASH_API="http://127.0.0.1:9090"
PASSED=0
FAILED=0
ERRORS=""
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
NC='\033[0m'
log() { echo -e "${YELLOW}[$1]${NC} $2"; }
pass() { echo -e "${GREEN}PASS${NC} $1"; ((PASSED++)); }
fail() { echo -e "${RED}FAIL${NC} $1: $2"; ((FAILED++)); ERRORS="$ERRORS\n - $1: $2"; }
# ---- Helpers (Compose UI — no resource-ids, match by text/content-desc) ----
UI_DUMP=/tmp/sing-test-ui.xml
ui_dump() {
adb shell uiautomator dump /sdcard/ui.xml 2>/dev/null
adb pull /sdcard/ui.xml "$UI_DUMP" 2>/dev/null
}
# Check if text exists in UI dump
ui_has_text() {
grep -q "text=\"$1\"" "$UI_DUMP"
}
# Get bounds of first node matching text="..."
ui_bounds_text() {
local text="$1"
grep -o "text=\"${text}\"[^/]*bounds=\"[^\"]*\"" "$UI_DUMP" | head -1 | sed -n 's/.*bounds="\([^"]*\)".*/\1/p'
}
# Get bounds of first node matching content-desc="..."
ui_bounds_cd() {
local cd="$1"
grep -o "content-desc=\"${cd}\"[^/]*bounds=\"[^\"]*\"" "$UI_DUMP" | head -1 | sed -n 's/.*bounds="\([^"]*\)".*/\1/p'
}
# Tap center of bounds string like [303,984][776,1457]
tap_bounds() {
local bounds="$1"
if [ -z "$bounds" ]; then return 1; fi
local x=$(echo "$bounds" | sed 's/\[//g;s/\]/ /g' | awk -F'[, ]' '{print int(($1+$3)/2)}')
local y=$(echo "$bounds" | sed 's/\[//g;s/\]/ /g' | awk -F'[, ]' '{print int(($2+$4)/2)}')
adb shell input tap "$x" "$y"
}
tap_text() {
ui_dump
local bounds=$(ui_bounds_text "$1")
if [ -z "$bounds" ]; then
# Fallback: try as content-desc
bounds=$(ui_bounds_cd "$1")
fi
tap_bounds "$bounds"
}
# Wait for text to appear in UI
wait_for_text() {
local text="$1" timeout="${2:-15}"
for i in $(seq 1 $timeout); do
ui_dump
if ui_has_text "$text"; then return 0; fi
sleep 1
done
return 1
}
# Wait for text to disappear from UI
wait_text_gone() {
local text="$1" timeout="${2:-15}"
for i in $(seq 1 $timeout); do
ui_dump
if ! ui_has_text "$text"; then return 0; fi
sleep 1
done
return 1
}
grant_vpn() {
sleep 1
tap_text "OK" 2>/dev/null || true
sleep 1
}
write_nodes() {
local json="$1"
local escaped=$(echo "$json" | sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g;s/"/\&quot;/g')
local xml="<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?><map><string name=\"nodes\">${escaped}</string><boolean name=\"initialized_v3\" value=\"true\" /></map>"
echo "$xml" | adb shell "run-as $PKG sh -c 'cat > /data/data/$PKG/shared_prefs/sing_nodes.xml'"
}
write_settings() {
local content="$1"
local xml="<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\" ?><map><int name=\"routing_mode\" value=\"0\" /><string name=\"dns_remote\">8.8.8.8</string><string name=\"dns_local\">223.5.5.5</string>${content}</map>"
echo "$xml" | adb shell "run-as $PKG sh -c 'cat > /data/data/$PKG/shared_prefs/sing_settings.xml'"
}
setup_ss_node() {
write_nodes '[{"id":"test-ss","name":"Test-SS","type":"shadowsocks","host":"c33s1.portablesubmarines.com","port":18188,"method":"aes-256-gcm","password":"7PPaArBey5sGTHqd","active":true,"tls":false,"reality":false,"wgMtu":1280,"profileId":""}]'
}
launch_app() {
adb shell am start -n "$PKG/.MainActivity" -a android.intent.action.MAIN -c android.intent.category.LAUNCHER
sleep 3
}
stop_app() {
adb shell am force-stop "$PKG"
sleep 1
}
is_service_running() {
adb shell dumpsys activity services "$PKG/.SingVpnService" | grep -q "ServiceRecord"
}
wait_service_stopped() {
local timeout="${1:-10}"
for i in $(seq 1 $timeout); do
if ! is_service_running; then return 0; fi
sleep 1
done
return 1
}
clash_api_get() {
curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 "$1" 2>/dev/null || echo "000"
}
clash_api_body() {
curl -s --connect-timeout 3 "$1" 2>/dev/null || echo ""
}
# ---- Tests ----
test_01_dashboard_renders() {
log "test01" "Dashboard renders with all cards"
stop_app
setup_ss_node
launch_app
ui_dump
local ok=true
for text in "Dashboard" "Offline" "Outbound Mode" "Connect" "Not connected" "Traffic" "Connection" "Active Connections"; do
if ui_has_text "$text"; then
log "test01" " Found: $text"
else
fail "test01_dashboard" "Missing text: $text"
ok=false
fi
done
if $ok; then
pass "test01_dashboard_renders"
fi
stop_app
}
test_02_tab_navigation() {
log "test02" "4-tab navigation works"
stop_app
setup_ss_node
launch_app
# Should start on Dashboard
ui_dump
if ! ui_has_text "Dashboard"; then
fail "test02_tabs" "Not on Dashboard tab"
stop_app; return
fi
# Navigate to Proxies
tap_text "Proxies"
sleep 1
ui_dump
if ! ui_has_text "Proxies" || ! ui_has_text "Test-SS"; then
fail "test02_tabs" "Proxies tab missing content"
stop_app; return
fi
log "test02" " Proxies tab OK"
# Navigate to Profiles
tap_text "Profiles"
sleep 1
ui_dump
if ! ui_has_text "No profiles yet"; then
fail "test02_tabs" "Profiles tab missing 'No profiles yet'"
stop_app; return
fi
log "test02" " Profiles tab OK"
# Navigate to Tools
tap_text "Tools"
sleep 1
ui_dump
if ! ui_has_text "Quick Access" || ! ui_has_text "Settings"; then
fail "test02_tabs" "Tools tab missing sections"
stop_app; return
fi
log "test02" " Tools tab OK"
# Back to Dashboard
tap_text "Dashboard"
sleep 1
ui_dump
if ! ui_has_text "Outbound Mode"; then
fail "test02_tabs" "Failed to return to Dashboard"
stop_app; return
fi
log "test02" " Back to Dashboard OK"
pass "test02_tab_navigation"
stop_app
}
test_03_outbound_mode() {
log "test03" "Outbound mode chips toggle"
stop_app
setup_ss_node
launch_app
ui_dump
# Default should be Rule-based (checked)
# Check that "Rule-based" chip is checkable+checked
if ! grep -q 'text="Rule-based"' "$UI_DUMP"; then
fail "test03_outbound" "Rule-based chip not found"
stop_app; return
fi
# Tap "Global proxy"
tap_text "Global proxy"
sleep 1
ui_dump
# Verify Global proxy chip is now checked
local global_node=$(grep -o 'checked="true"[^/]*text="Global proxy"' "$UI_DUMP" || grep -o 'text="Global proxy"' "$UI_DUMP")
log "test03" " Tapped Global proxy"
# Tap "Direct"
tap_text "Direct"
sleep 1
log "test03" " Tapped Direct"
# Tap back to "Rule-based"
tap_text "Rule-based"
sleep 1
log "test03" " Tapped Rule-based"
pass "test03_outbound_mode"
stop_app
}
test_04_connect_disconnect() {
log "test04" "Connect and disconnect VPN"
stop_app
setup_ss_node
launch_app
# Should show "Connect" and "Not connected"
ui_dump
if ! ui_has_text "Connect"; then
fail "test04_connect" "Connect button not found"
stop_app; return
fi
# Tap connect card
tap_text "Connect"
grant_vpn
# Wait for status to show "Online"
if ! wait_for_text "Online" 20; then
# Check if VPN service started
if is_service_running; then
log "test04" " Service running but UI not updated yet"
else
fail "test04_connect" "VPN did not connect (service not running)"
stop_app; return
fi
fi
log "test04" " VPN connected"
# Verify text changed to "Disconnect"
ui_dump
if ! ui_has_text "Disconnect"; then
fail "test04_connect" "Disconnect button not shown after connect"
stop_app; return
fi
# Check Clash API via port forward
sleep 3
local code=$(clash_api_get "$CLASH_API/connections")
if [ "$code" = "200" ]; then
log "test04" " Clash API responding (200)"
else
log "test04" " Clash API returned $code (may need more time)"
fi
# Check traffic cards update
ui_dump
local active_text=$(grep -o 'text="[0-9]*"' "$UI_DUMP" | head -1)
log "test04" " Active connections: $active_text"
# Disconnect
tap_text "Disconnect"
if ! wait_for_text "Connect" 15; then
fail "test04_disconnect" "VPN did not disconnect"
stop_app; return
fi
if ! wait_service_stopped 10; then
fail "test04_disconnect" "VPN service still running"
stop_app; return
fi
log "test04" " VPN disconnected"
pass "test04_connect_disconnect"
stop_app
}
test_05_traffic_stats() {
log "test05" "Traffic stats update when connected"
stop_app
setup_ss_node
launch_app
tap_text "Connect"
grant_vpn
if ! wait_for_text "Online" 20; then
fail "test05_traffic" "VPN did not connect"
stop_app; return
fi
# Wait for traffic polling to kick in
sleep 5
# Check Clash API for traffic data
local body=$(clash_api_body "$CLASH_API/connections")
local upload=$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('uploadTotal',0))" 2>/dev/null || echo "0")
local download=$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('downloadTotal',0))" 2>/dev/null || echo "0")
log "test05" " Clash API: upload=$upload download=$download"
# Generate some traffic through the VPN
adb shell "curl -s -o /dev/null -w '%{http_code}' https://www.google.com" 2>/dev/null || true
sleep 3
local body2=$(clash_api_body "$CLASH_API/connections")
local upload2=$(echo "$body2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('uploadTotal',0))" 2>/dev/null || echo "0")
local download2=$(echo "$body2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('downloadTotal',0))" 2>/dev/null || echo "0")
local conns=$(echo "$body2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('connections',[])))" 2>/dev/null || echo "0")
log "test05" " After traffic: upload=$upload2 download=$download2 conns=$conns"
if [ "$download2" -gt "0" ] 2>/dev/null; then
pass "test05_traffic_stats"
else
fail "test05_traffic" "No traffic recorded (download=$download2)"
fi
tap_text "Disconnect"
wait_for_text "Connect" 10
stop_app
}
test_06_tools_navigation() {
log "test06" "Tools tab sub-screen navigation"
stop_app
setup_ss_node
launch_app
# Go to Tools tab
tap_text "Tools"
sleep 1
ui_dump
if ! ui_has_text "Quick Access" || ! ui_has_text "Settings"; then
fail "test06_tools" "Tools tab missing sections"
stop_app; return
fi
# Navigate to General settings
tap_text "General"
sleep 1
ui_dump
if ! ui_has_text "Auto-connect" || ! ui_has_text "IPv6"; then
fail "test06_general" "General settings missing content"
stop_app; return
fi
log "test06" " General settings OK"
# Go back
tap_text "Back" 2>/dev/null || adb shell input keyevent KEYCODE_BACK
sleep 1
# Navigate to Network & DNS
tap_text "Network"
sleep 1
ui_dump
if ! ui_has_text "Remote" && ! ui_has_text "Local" && ! ui_has_text "DNS"; then
fail "test06_network" "Network settings missing content"
stop_app; return
fi
log "test06" " Network settings OK"
adb shell input keyevent KEYCODE_BACK
sleep 1
# Navigate to Tailscale
tap_text "Tailscale"
sleep 1
ui_dump
if ! ui_has_text "Enabled"; then
fail "test06_tailscale" "Tailscale settings missing content"
stop_app; return
fi
log "test06" " Tailscale settings OK"
adb shell input keyevent KEYCODE_BACK
sleep 1
pass "test06_tools_navigation"
stop_app
}
test_07_reconnect() {
log "test07" "Reconnect after disconnect"
stop_app
setup_ss_node
launch_app
# First connect
tap_text "Connect"
grant_vpn
if ! wait_for_text "Online" 20; then
fail "test07_first_connect" "First connect failed"
stop_app; return
fi
sleep 2
# Disconnect
tap_text "Disconnect"
if ! wait_for_text "Connect" 15; then
fail "test07_disconnect" "Disconnect failed"
stop_app; return
fi
sleep 2
# Reconnect
tap_text "Connect"
grant_vpn
if ! wait_for_text "Online" 20; then
fail "test07_reconnect" "Reconnect failed"
stop_app; return
fi
sleep 2
local code=$(clash_api_get "$CLASH_API/connections")
if [ "$code" = "200" ]; then
pass "test07_reconnect"
else
fail "test07_reconnect" "Clash API returned $code after reconnect"
fi
tap_text "Disconnect"
wait_for_text "Connect" 10
stop_app
}
test_08_profiles_add() {
log "test08" "Profiles tab — add profile UI"
stop_app
setup_ss_node
launch_app
# Navigate to Profiles tab
tap_text "Profiles"
sleep 1
ui_dump
if ! ui_has_text "No profiles yet"; then
fail "test08_profiles" "Profiles tab initial state wrong"
stop_app; return
fi
log "test08" " Empty state OK"
# Tap add button (the + icon)
# Find the Add Profile button by content-desc
local add_bounds=$(ui_bounds_cd "Add Profile")
if [ -z "$add_bounds" ]; then
# Try finding by the "+" area near the header
add_bounds=$(grep -o 'content-desc="Add Profile"[^/]*bounds="[^"]*"' "$UI_DUMP" | head -1 | sed -n 's/.*bounds="\([^"]*\)".*/\1/p')
fi
if [ -n "$add_bounds" ]; then
tap_bounds "$add_bounds"
sleep 1
ui_dump
if ui_has_text "Subscription URL" || ui_has_text "Import"; then
log "test08" " Add profile sheet opened"
pass "test08_profiles_add"
else
fail "test08_profiles" "Add sheet didn't open"
fi
else
# Try tapping the icon area (top right of profiles screen)
log "test08" " Add button not found by content-desc, trying area tap"
fail "test08_profiles" "Add Profile button not found"
fi
stop_app
}
# ---- Runner ----
main() {
echo "========================================="
echo " Sing VPN E2E Tests (Compose UI)"
echo "========================================="
echo ""
# Verify device
if ! adb get-state >/dev/null 2>&1; then
echo "ERROR: No device connected"; exit 1
fi
# Forward Clash API port
adb forward tcp:9090 tcp:9090 2>/dev/null || true
local filter="${1:-all}"
if [ "$filter" = "all" ] || [ "$filter" = "test01" ]; then test_01_dashboard_renders; fi
if [ "$filter" = "all" ] || [ "$filter" = "test02" ]; then test_02_tab_navigation; fi
if [ "$filter" = "all" ] || [ "$filter" = "test03" ]; then test_03_outbound_mode; fi
if [ "$filter" = "all" ] || [ "$filter" = "test04" ]; then test_04_connect_disconnect; fi
if [ "$filter" = "all" ] || [ "$filter" = "test05" ]; then test_05_traffic_stats; fi
if [ "$filter" = "all" ] || [ "$filter" = "test06" ]; then test_06_tools_navigation; fi
if [ "$filter" = "all" ] || [ "$filter" = "test07" ]; then test_07_reconnect; fi
if [ "$filter" = "all" ] || [ "$filter" = "test08" ]; then test_08_profiles_add; fi
echo ""
echo "========================================="
echo -e " Results: ${GREEN}${PASSED} passed${NC}, ${RED}${FAILED} failed${NC}"
if [ $FAILED -gt 0 ]; then
echo -e " Failures:${ERRORS}"
fi
echo "========================================="
[ $FAILED -eq 0 ]
}
main "$@"