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.
562 lines
16 KiB
Bash
Executable File
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/&/\&/g;s/</\</g;s/>/\>/g;s/"/\"/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 "$@"
|