diff --git a/.github/actions/bandwidth-throttling-linux/action.yml b/.github/actions/bandwidth-throttling-linux/action.yml new file mode 100644 index 0000000000..e633bdb959 --- /dev/null +++ b/.github/actions/bandwidth-throttling-linux/action.yml @@ -0,0 +1,168 @@ +name: bandwidth-throttling-linux +description: Action to throttle the bandwidth on Linux runners (for Android E2E) using tc/netem + +inputs: + test_server_host: + description: The host of the test server, no protocol + required: true + profile: + description: "Network profile to use (lte, slow_3g, edge_2g, satellite, flapping). Overrides individual settings." + required: false + default: "" + download_speed: + description: The download speed limit (in Kbit/s) - used if profile is not set + required: false + default: "3300" + upload_speed: + description: The upload speed limit (in Kbit/s) - used if profile is not set + required: false + default: "3300" + latency: + description: The latency (in ms) each way - used if profile is not set + required: false + default: "500" + packet_loss: + description: Packet loss percentage (0-100) - used if profile is not set + required: false + default: "0" + disable: + description: Disable throttling + required: false + default: "false" + +outputs: + effective_profile: + description: The profile that was applied + value: ${{ steps.resolve-profile.outputs.profile }} + timeout_multiplier: + description: Recommended timeout multiplier for this profile + value: ${{ steps.resolve-profile.outputs.timeout_multiplier }} + +runs: + using: composite + steps: + - name: Resolve network profile settings + id: resolve-profile + shell: bash + run: | + PROFILE="${{ inputs.profile }}" + + # Profile-based settings (same as macOS version) + case "$PROFILE" in + lte) + DOWNLOAD=10000 + UPLOAD=5000 + LATENCY=30 + PACKET_LOSS=0 + TIMEOUT_MULT=1 + ;; + slow_3g) + DOWNLOAD=400 + UPLOAD=128 + LATENCY=300 + PACKET_LOSS=2 + TIMEOUT_MULT=3 + ;; + edge_2g) + DOWNLOAD=50 + UPLOAD=25 + LATENCY=500 + PACKET_LOSS=5 + TIMEOUT_MULT=10 + ;; + satellite) + DOWNLOAD=1000 + UPLOAD=256 + LATENCY=700 + PACKET_LOSS=1 + TIMEOUT_MULT=5 + ;; + flapping) + DOWNLOAD=1000 + UPLOAD=256 + LATENCY=200 + PACKET_LOSS=0 + TIMEOUT_MULT=5 + ;; + *) + DOWNLOAD=${{ inputs.download_speed }} + UPLOAD=${{ inputs.upload_speed }} + LATENCY=${{ inputs.latency }} + PACKET_LOSS=${{ inputs.packet_loss }} + TIMEOUT_MULT=1 + PROFILE="custom" + ;; + esac + + echo "download=$DOWNLOAD" >> $GITHUB_OUTPUT + echo "upload=$UPLOAD" >> $GITHUB_OUTPUT + echo "latency=$LATENCY" >> $GITHUB_OUTPUT + echo "packet_loss=$PACKET_LOSS" >> $GITHUB_OUTPUT + echo "timeout_multiplier=$TIMEOUT_MULT" >> $GITHUB_OUTPUT + echo "profile=$PROFILE" >> $GITHUB_OUTPUT + + echo "Network profile: $PROFILE" + echo " Download: ${DOWNLOAD} Kbit/s" + echo " Upload: ${UPLOAD} Kbit/s" + echo " Latency: ${LATENCY} ms" + echo " Packet Loss: ${PACKET_LOSS}%" + echo " Timeout Multiplier: ${TIMEOUT_MULT}x" + + - name: Disable existing throttling + if: ${{ inputs.disable == 'true' }} + shell: bash + run: | + # Remove any existing tc rules + sudo tc qdisc del dev eth0 root 2>/dev/null || true + echo "Network throttling disabled" + + - name: Apply network throttling with tc/netem + if: ${{ inputs.disable != 'true' }} + shell: bash + run: | + # Remove any existing rules + sudo tc qdisc del dev eth0 root 2>/dev/null || true + + DOWNLOAD="${{ steps.resolve-profile.outputs.download }}" + LATENCY="${{ steps.resolve-profile.outputs.latency }}" + PACKET_LOSS="${{ steps.resolve-profile.outputs.packet_loss }}" + + echo "Applying network throttling..." + + # Build netem options + NETEM_OPTS="delay ${LATENCY}ms" + if [ "$PACKET_LOSS" != "0" ]; then + NETEM_OPTS="$NETEM_OPTS loss ${PACKET_LOSS}%" + fi + + # Add root qdisc with netem for delay and packet loss + sudo tc qdisc add dev eth0 root handle 1: netem $NETEM_OPTS + + # Add tbf (token bucket filter) for rate limiting + # burst = rate / 8 (bytes per second / 8 = 1 second of buffer) + # latency = how long packets can wait in queue + RATE="${DOWNLOAD}kbit" + BURST="$((DOWNLOAD / 8))kb" + + sudo tc qdisc add dev eth0 parent 1: handle 2: tbf rate $RATE burst $BURST latency 50ms + + echo "Network throttling applied:" + echo " Profile: ${{ steps.resolve-profile.outputs.profile }}" + sudo tc qdisc show dev eth0 + + - name: Start flapping simulation + if: ${{ inputs.profile == 'flapping' && inputs.disable != 'true' }} + shell: bash + run: | + # Start the flapping script in background + nohup ${{ github.action_path }}/flapping-linux.sh & + FLAPPING_PID=$! + echo "FLAPPING_PID=$FLAPPING_PID" >> $GITHUB_ENV + echo "Started flapping simulation with PID: $FLAPPING_PID" + + - name: Test connection after throttling + if: ${{ inputs.disable != 'true' }} + shell: bash + run: | + echo "Testing connection with throttling applied..." + curl -o /dev/null -m 30 --retry 2 -s -w 'Total: %{time_total}s\n' 'https://${{ inputs.test_server_host }}/api/v4/system/ping?get_server_status=true' || echo "Connection test completed (may have timed out under heavy throttling)" diff --git a/.github/actions/bandwidth-throttling-linux/flapping-linux.sh b/.github/actions/bandwidth-throttling-linux/flapping-linux.sh new file mode 100755 index 0000000000..d35184de40 --- /dev/null +++ b/.github/actions/bandwidth-throttling-linux/flapping-linux.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Flapping Network Simulation Script for Linux (tc/netem) +# Simulates intermittent connectivity by cycling through network states + +set -e + +# Detect the primary network interface +INTERFACE=$(ip route | grep default | awk '{print $5}' | head -n1) +if [ -z "$INTERFACE" ]; then + INTERFACE="eth0" +fi + +echo "Starting flapping network simulation (Linux)" +echo "Using network interface: $INTERFACE" +echo "PID: $$" + +# Cleanup function to reset network on exit (preserves exit status) +cleanup() { + local exit_status=$? + echo "[$(date '+%H:%M:%S')] Cleaning up tc rules..." + sudo tc qdisc del dev "$INTERFACE" root 2>/dev/null || true + exit $exit_status +} +trap cleanup SIGTERM SIGINT EXIT + +# Function to apply network settings using tc/netem +apply_settings() { + local download=$1 + local latency=$2 + local packet_loss=$3 + local state_name=$4 + + echo "[$(date '+%H:%M:%S')] Switching to state: $state_name" + + # Remove existing rules + sudo tc qdisc del dev "$INTERFACE" root 2>/dev/null || true + + if [ "$state_name" = "disconnected" ]; then + # 100% packet loss = disconnected + sudo tc qdisc add dev "$INTERFACE" root netem loss 100% + else + # Build netem options + NETEM_OPTS="delay ${latency}ms" + if [ "$packet_loss" != "0" ]; then + NETEM_OPTS="$NETEM_OPTS loss ${packet_loss}%" + fi + + # Add netem for delay/loss + sudo tc qdisc add dev "$INTERFACE" root handle 1: netem $NETEM_OPTS + + # Add rate limiting + RATE="${download}kbit" + BURST="$((download / 8))kb" + sudo tc qdisc add dev "$INTERFACE" parent 1: handle 2: tbf rate $RATE burst $BURST latency 50ms + fi +} + +# Flapping pattern loop +cycle=0 +while true; do + cycle=$((cycle + 1)) + echo "[$(date '+%H:%M:%S')] === Flapping cycle $cycle ===" + + # State 1: Connected (good connection) + apply_settings 1000 200 0 "connected" + sleep 30 + + # State 2: Disconnected + apply_settings 0 0 100 "disconnected" + sleep 5 + + # State 3: Slow 3G + apply_settings 400 300 2 "slow_3g" + sleep 30 + + # State 4: Brief disconnection + apply_settings 0 0 100 "disconnected" + sleep 3 +done diff --git a/.github/actions/bandwidth-throttling/action.yml b/.github/actions/bandwidth-throttling/action.yml index 70cb4b8c7e..8f752c80da 100644 --- a/.github/actions/bandwidth-throttling/action.yml +++ b/.github/actions/bandwidth-throttling/action.yml @@ -1,71 +1,185 @@ name: bandwidth-throttling -description: Action to throttle the bandwidth on MacOS runner +description: Action to throttle the bandwidth on MacOS runner with support for network profiles inputs: test_server_host: description: The host of the test server, no protocol required: true + profile: + description: "Network profile to use (lte, slow_3g, edge_2g, satellite, flapping). Overrides individual settings." + required: false + default: "" download_speed: - description: The download speed limit (in Kbit/s) + description: The download speed limit (in Kbit/s) - used if profile is not set required: false default: "3300" upload_speed: - description: The upload speed limit (in Kbit/s) + description: The upload speed limit (in Kbit/s) - used if profile is not set required: false default: "3300" latency: - description: The latency (in ms) each way + description: The latency (in ms) each way - used if profile is not set required: false default: "500" + packet_loss: + description: Packet loss percentage (0-100) - used if profile is not set + required: false + default: "0" disable: description: Disable throttling required: false default: "false" +outputs: + effective_profile: + description: The profile that was applied + value: ${{ steps.resolve-profile.outputs.profile }} + timeout_multiplier: + description: Recommended timeout multiplier for this profile + value: ${{ steps.resolve-profile.outputs.timeout_multiplier }} + runs: using: composite steps: - - name: disable first + - name: Resolve network profile settings + id: resolve-profile + shell: bash + run: | + PROFILE="${{ inputs.profile }}" + + # Profile-based settings + case "$PROFILE" in + lte) + DOWNLOAD=10000 + UPLOAD=5000 + LATENCY=30 + PACKET_LOSS=0 + TIMEOUT_MULT=1 + ;; + slow_3g) + DOWNLOAD=400 + UPLOAD=128 + LATENCY=300 + PACKET_LOSS=2 + TIMEOUT_MULT=3 + ;; + edge_2g) + DOWNLOAD=50 + UPLOAD=25 + LATENCY=500 + PACKET_LOSS=5 + TIMEOUT_MULT=10 + ;; + satellite) + DOWNLOAD=1000 + UPLOAD=256 + LATENCY=700 + PACKET_LOSS=1 + TIMEOUT_MULT=5 + ;; + flapping) + # Base settings for flapping - actual flapping handled separately + DOWNLOAD=1000 + UPLOAD=256 + LATENCY=200 + PACKET_LOSS=0 + TIMEOUT_MULT=5 + ;; + *) + # Use individual inputs if no profile specified + DOWNLOAD=${{ inputs.download_speed }} + UPLOAD=${{ inputs.upload_speed }} + LATENCY=${{ inputs.latency }} + PACKET_LOSS=${{ inputs.packet_loss }} + TIMEOUT_MULT=1 + PROFILE="custom" + ;; + esac + + echo "download=$DOWNLOAD" >> $GITHUB_OUTPUT + echo "upload=$UPLOAD" >> $GITHUB_OUTPUT + echo "latency=$LATENCY" >> $GITHUB_OUTPUT + echo "packet_loss=$PACKET_LOSS" >> $GITHUB_OUTPUT + echo "timeout_multiplier=$TIMEOUT_MULT" >> $GITHUB_OUTPUT + echo "profile=$PROFILE" >> $GITHUB_OUTPUT + + echo "Network profile: $PROFILE" + echo " Download: ${DOWNLOAD} Kbit/s" + echo " Upload: ${UPLOAD} Kbit/s" + echo " Latency: ${LATENCY} ms" + echo " Packet Loss: ${PACKET_LOSS}%" + echo " Timeout Multiplier: ${TIMEOUT_MULT}x" + + - name: Disable existing throttling first if: ${{ inputs.disable == 'true' }} shell: bash continue-on-error: true run: | - sudo pfctl -d - - sleep 2; + sudo pfctl -d 2>/dev/null || true + sudo dnctl -q flush 2>/dev/null || true + sudo dnctl -q pipe flush 2>/dev/null || true + sleep 2 - - name: throttle bandwidth down + - name: Apply network throttling + if: ${{ inputs.disable != 'true' }} shell: bash run: | - # reset pf and dnctl - sudo dnctl -q flush - sudo dnctl -q pipe flush - sudo pfctl -f /etc/pf.conf - sudo pfctl -E - - sleep 2; + # Reset pf and dnctl + sudo dnctl -q flush 2>/dev/null || true + sudo dnctl -q pipe flush 2>/dev/null || true + sudo pfctl -f /etc/pf.conf 2>/dev/null || true + sudo pfctl -E 2>/dev/null || true - sudo pfctl -d - sudo dnctl -q flush - sudo dnctl -q pipe flush + sleep 2 + sudo pfctl -d 2>/dev/null || true + sudo dnctl -q flush 2>/dev/null || true + sudo dnctl -q pipe flush 2>/dev/null || true + # Set up packet filter rules echo "dummynet in from ${{ inputs.test_server_host }} to ! 127.0.0.1 pipe 1 dummynet out from ! 127.0.0.1 to ${{ inputs.test_server_host }} pipe 2" | sudo pfctl -f - - # pipe 1 is download - sudo dnctl pipe 1 config bw ${{ inputs.download_speed }}Kbit/s delay ${{ inputs.latency }}ms + # Configure download pipe (pipe 1) + DOWNLOAD_OPTS="bw ${{ steps.resolve-profile.outputs.download }}Kbit/s delay ${{ steps.resolve-profile.outputs.latency }}ms" + if [ "${{ steps.resolve-profile.outputs.packet_loss }}" != "0" ]; then + # Convert percentage to decimal (e.g., 2% -> 0.02) + PLR=$(echo "scale=4; ${{ steps.resolve-profile.outputs.packet_loss }} / 100" | bc) + DOWNLOAD_OPTS="$DOWNLOAD_OPTS plr $PLR" + fi + sudo dnctl pipe 1 config $DOWNLOAD_OPTS - # pipe 2 is upload - sudo dnctl pipe 2 config bw ${{ inputs.upload_speed }}Kbit/s delay ${{ inputs.latency }}ms + # Configure upload pipe (pipe 2) + UPLOAD_OPTS="bw ${{ steps.resolve-profile.outputs.upload }}Kbit/s delay ${{ steps.resolve-profile.outputs.latency }}ms" + if [ "${{ steps.resolve-profile.outputs.packet_loss }}" != "0" ]; then + PLR=$(echo "scale=4; ${{ steps.resolve-profile.outputs.packet_loss }} / 100" | bc) + UPLOAD_OPTS="$UPLOAD_OPTS plr $PLR" + fi + sudo dnctl pipe 2 config $UPLOAD_OPTS - sleep 5; + sleep 5 sudo pfctl -E - sleep 5; + sleep 5 + + echo "Network throttling applied:" + echo " Profile: ${{ steps.resolve-profile.outputs.profile }}" + sudo dnctl show + + - name: Start flapping simulation + if: ${{ inputs.profile == 'flapping' && inputs.disable != 'true' }} + shell: bash + run: | + # Start the flapping script in background + nohup ${{ github.action_path }}/flapping.sh "${{ inputs.test_server_host }}" & + FLAPPING_PID=$! + echo "FLAPPING_PID=$FLAPPING_PID" >> $GITHUB_ENV + echo "Started flapping simulation with PID: $FLAPPING_PID" - - name: test curl after throttling + - name: Test connection after throttling + if: ${{ inputs.disable != 'true' }} shell: bash run: | - curl -o /dev/null -m 20 --retry 2 -s -w 'Total: %{time_total}s\n' 'https://${{ inputs.test_server_host }}/api/v4/system/ping?get_server_status=true' + echo "Testing connection with throttling applied..." + curl -o /dev/null -m 30 --retry 2 -s -w 'Total: %{time_total}s\n' 'https://${{ inputs.test_server_host }}/api/v4/system/ping?get_server_status=true' diff --git a/.github/actions/bandwidth-throttling/flapping.sh b/.github/actions/bandwidth-throttling/flapping.sh new file mode 100755 index 0000000000..89c5322d8e --- /dev/null +++ b/.github/actions/bandwidth-throttling/flapping.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Flapping Network Simulation Script +# Simulates intermittent connectivity by cycling through network states +# +# Usage: ./flapping.sh +# Example: ./flapping.sh mobile-e2e-site-1.test.mattermost.cloud + +set -e + +# TEST_SERVER_HOST is kept for logging/diagnostic purposes only +TEST_SERVER_HOST="${1:-unspecified}" + +echo "Starting flapping network simulation for host: $TEST_SERVER_HOST" +echo "PID: $$" + +# Cleanup function to reset network on exit +cleanup() { + echo "[$(date '+%H:%M:%S')] Cleaning up dnctl pipes..." + sudo dnctl -q pipe flush 2>/dev/null || true + exit 0 +} +trap cleanup SIGTERM SIGINT EXIT + +# Function to apply network settings +apply_settings() { + local download=$1 + local upload=$2 + local latency=$3 + local packet_loss=$4 + local state_name=$5 + + echo "[$(date '+%H:%M:%S')] Switching to state: $state_name" + + # Reset pipes + sudo dnctl -q pipe flush 2>/dev/null || true + + if [ "$state_name" = "disconnected" ]; then + # 100% packet loss = disconnected + sudo dnctl pipe 1 config bw 1Kbit/s plr 1.0 + sudo dnctl pipe 2 config bw 1Kbit/s plr 1.0 + else + # Configure with specified settings + DOWNLOAD_OPTS="bw ${download}Kbit/s delay ${latency}ms" + UPLOAD_OPTS="bw ${upload}Kbit/s delay ${latency}ms" + + if [ "$packet_loss" != "0" ]; then + PLR=$(echo "scale=4; $packet_loss / 100" | bc) + DOWNLOAD_OPTS="$DOWNLOAD_OPTS plr $PLR" + UPLOAD_OPTS="$UPLOAD_OPTS plr $PLR" + fi + + sudo dnctl pipe 1 config $DOWNLOAD_OPTS + sudo dnctl pipe 2 config $UPLOAD_OPTS + fi +} + +# Flapping pattern loop +# Pattern: connected(30s) -> disconnected(5s) -> slow_3g(30s) -> disconnected(3s) -> repeat +cycle=0 +while true; do + cycle=$((cycle + 1)) + echo "[$(date '+%H:%M:%S')] === Flapping cycle $cycle ===" + + # State 1: Connected (good connection) + apply_settings 1000 256 200 0 "connected" + sleep 30 + + # State 2: Disconnected + apply_settings 0 0 0 100 "disconnected" + sleep 5 + + # State 3: Slow 3G + apply_settings 400 128 300 2 "slow_3g" + sleep 30 + + # State 4: Brief disconnection + apply_settings 0 0 0 100 "disconnected" + sleep 3 + + # Back to connected for next cycle +done diff --git a/.github/actions/bandwidth-throttling/profiles.yml b/.github/actions/bandwidth-throttling/profiles.yml new file mode 100644 index 0000000000..c4702f39ab --- /dev/null +++ b/.github/actions/bandwidth-throttling/profiles.yml @@ -0,0 +1,88 @@ +# Network Degradation Profiles for DDIL Testing +# These profiles simulate various real-world network conditions +# +# Usage: Pass the profile name to the bandwidth-throttling action +# Example: profile: slow_3g +# +# Reference: +# - ITU-T G.114: 0-150ms one-way delay preferred, 150-400ms acceptable +# - STANAG 5066: HF radio data communications (2.4-9.6 kbps typical) +# - 3GPP specs: Mission-critical communications requirements + +profiles: + # Baseline - good LTE connection (for comparison) + lte: + description: "Good LTE/4G connection - baseline for comparison" + download_kbps: 10000 + upload_kbps: 5000 + latency_ms: 30 + packet_loss_percent: 0 + use_case: "Urban 4G, optimal conditions" + + # Degraded 3G - rural or congested areas + slow_3g: + description: "Slow 3G - rural or congested network" + download_kbps: 400 + upload_kbps: 128 + latency_ms: 300 + packet_loss_percent: 2 + use_case: "Rural coverage, network congestion, indoor" + + # Edge/2G - minimal connectivity + edge_2g: + description: "Edge/2G - minimal cellular connectivity" + download_kbps: 50 + upload_kbps: 25 + latency_ms: 500 + packet_loss_percent: 5 + use_case: "Remote areas, basement, elevator transition" + + # Satellite - high latency + satellite: + description: "GEO Satellite - high latency connection" + download_kbps: 1000 + upload_kbps: 256 + latency_ms: 700 + packet_loss_percent: 1 + use_case: "Maritime, aviation, remote installations" + + # Flapping - intermittent connectivity (special handling required) + flapping: + description: "Intermittent connectivity - connection drops and recovers" + # Base settings when connected + download_kbps: 1000 + upload_kbps: 256 + latency_ms: 200 + packet_loss_percent: 0 + # NOTE: This pattern is documentation only - actual behavior is hardcoded in + # flapping.sh (macOS) and flapping-linux.sh. Update those scripts to change timing. + pattern: + - state: "connected" + duration_sec: 30 + - state: "disconnected" + duration_sec: 5 + - state: "slow_3g" + duration_sec: 30 + - state: "disconnected" + duration_sec: 3 + - state: "connected" + duration_sec: 30 + use_case: "Tunnel, elevator, train, moving vehicle" + +# Timeout multipliers for each profile +# Tests should multiply their standard timeouts by this factor +timeout_multipliers: + lte: 1 + slow_3g: 3 + edge_2g: 10 + satellite: 5 + flapping: 5 + +# Recommended test subsets per profile +# Running full test suite under severe degradation is too slow +test_scope: + lte: "full" # Run all tests + slow_3g: "critical" # Run critical path only + edge_2g: "minimal" # Run minimal smoke tests + satellite: "critical" # Run critical path only + flapping: "minimal" # Run minimal smoke tests diff --git a/.github/actions/prepare-low-bandwidth/action.yml b/.github/actions/prepare-low-bandwidth/action.yml index b39b77c27a..a2b9a0041e 100644 --- a/.github/actions/prepare-low-bandwidth/action.yml +++ b/.github/actions/prepare-low-bandwidth/action.yml @@ -1,5 +1,5 @@ name: Prepare Low Bandwidth Environment (MacOS & iOS Simulators only) -description: prepare any workflow for low bandwidth testing +description: Prepare any workflow for low bandwidth or degraded network testing inputs: test_server_url: @@ -8,60 +8,85 @@ inputs: device_name: description: The iOS simulator name required: true + network_profile: + description: "Network profile to use (lte, slow_3g, edge_2g, satellite, flapping)" + required: false + default: "" download_speed: - description: The download speed limit (in Kbit/s) + description: The download speed limit (in Kbit/s) - used if profile is not set required: false default: "3300" upload_speed: - description: The upload speed limit (in Kbit/s) + description: The upload speed limit (in Kbit/s) - used if profile is not set required: false default: "3300" latency: - description: The latency (in ms) each way + description: The latency (in ms) each way - used if profile is not set required: false default: "500" +outputs: + effective_profile: + description: The network profile that was applied + value: ${{ steps.throttle-bandwidth.outputs.effective_profile }} + timeout_multiplier: + description: Recommended timeout multiplier for this profile + value: ${{ steps.throttle-bandwidth.outputs.timeout_multiplier }} runs: using: composite steps: - - name: delete the zip file and trash (to free up space) + - name: Delete the zip file and trash (to free up space) shell: bash run: | rm -rf mobile-artifacts/*.zip sudo rm -rf ~/.Trash/* - - name: check disk space + - name: Check disk space shell: bash run: df -h - - name: remove protocol from SITE_1_URL + - name: Remove protocol from test server URL id: remove-protocol shell: bash run: | echo "SITE_1_HOST=${{ inputs.test_server_url }}" | sed -e 's/http:\/\///g' -e 's/https:\/\///g' >> ${GITHUB_OUTPUT} - - name: Throttle Bandwidth 1 + - name: Throttle Bandwidth (attempt 1) id: throttle-bandwidth-1 continue-on-error: true uses: ./.github/actions/bandwidth-throttling with: test_server_host: ${{ steps.remove-protocol.outputs.SITE_1_HOST }} + profile: ${{ inputs.network_profile }} download_speed: ${{ inputs.download_speed }} upload_speed: ${{ inputs.upload_speed }} latency: ${{ inputs.latency }} - - name: Throttle Bandwidth 2 + - name: Throttle Bandwidth (attempt 2 with reset) if: steps.throttle-bandwidth-1.outcome != 'success' id: throttle-bandwidth-2 uses: ./.github/actions/bandwidth-throttling with: test_server_host: ${{ steps.remove-protocol.outputs.SITE_1_HOST }} - download_speed: ${{ inputs.download_speed}} + profile: ${{ inputs.network_profile }} + download_speed: ${{ inputs.download_speed }} upload_speed: ${{ inputs.upload_speed }} latency: ${{ inputs.latency }} disable: "true" + - name: Set throttle outputs + id: throttle-bandwidth + shell: bash + run: | + if [ "${{ steps.throttle-bandwidth-1.outcome }}" == "success" ]; then + echo "effective_profile=${{ steps.throttle-bandwidth-1.outputs.effective_profile }}" >> $GITHUB_OUTPUT + echo "timeout_multiplier=${{ steps.throttle-bandwidth-1.outputs.timeout_multiplier }}" >> $GITHUB_OUTPUT + else + echo "effective_profile=${{ steps.throttle-bandwidth-2.outputs.effective_profile }}" >> $GITHUB_OUTPUT + echo "timeout_multiplier=${{ steps.throttle-bandwidth-2.outputs.timeout_multiplier }}" >> $GITHUB_OUTPUT + fi + - name: Install mitmproxy & pm2 (process manager) id: install-mitmproxy-pm2 shell: bash @@ -89,7 +114,7 @@ runs: simulator_udid=$(xcrun simctl list devices "${{ inputs.device_name }}" -j | jq '.devices' | jq '."com.apple.CoreSimulator.SimRuntime.iOS-17-4"[0]["udid"]') echo "simulator_udid="$(echo $simulator_udid) >> ${GITHUB_OUTPUT} - - name: install certificate + - name: Install certificate shell: bash run: | sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem @@ -101,6 +126,6 @@ runs: sleep 5; - - name: show me booted simulators + - name: Show booted simulators shell: bash run: xcrun simctl list devices booted | grep Booted diff --git a/.github/workflows/e2e-android-template.yml b/.github/workflows/e2e-android-template.yml index f1ac8ff2ce..ebbd340fd2 100644 --- a/.github/workflows/e2e-android-template.yml +++ b/.github/workflows/e2e-android-template.yml @@ -39,10 +39,15 @@ on: type: string default: 'false' low_bandwidth_mode: - description: "Enable low bandwidth mode" + description: "Enable low bandwidth mode (legacy - prefer network_profile)" required: false type: boolean default: false + network_profile: + description: "Network degradation profile (lte, slow_3g, edge_2g, satellite, flapping)" + required: false + type: string + default: "" android_avd_name: description: "Android Emulator name" required: false @@ -218,12 +223,37 @@ jobs: env: JAVA_HOME: ${{ env.JAVA_HOME_17_X64 }} + - name: Remove protocol from test server URL + id: remove-protocol + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} + run: | + echo "SITE_1_HOST=${{ env.SITE_1_URL }}" | sed -e 's/http:\/\///g' -e 's/https:\/\///g' >> ${GITHUB_OUTPUT} + + - name: Apply Network Throttling + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} + uses: ./.github/actions/bandwidth-throttling-linux + with: + test_server_host: ${{ steps.remove-protocol.outputs.SITE_1_HOST }} + profile: ${{ inputs.network_profile }} + download_speed: "3300" + upload_speed: "3300" + latency: "500" + - name: Create and run Android Emulator run: | cd detox chmod +x ./create_android_emulator.sh CI=true ./create_android_emulator.sh ${{ env.SDK_VERSION }} ${{ env.AVD_NAME }} ${{ matrix.specs }} continue-on-error: true # We want to run all the tests + env: + LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} + NETWORK_PROFILE: ${{ inputs.network_profile }} + + - name: Reset Network Settings + if: ${{ (inputs.low_bandwidth_mode || inputs.network_profile != '') && always() }} + run: | + sudo tc qdisc del dev eth0 root 2>/dev/null || true + echo "Network throttling disabled" - name: Upload Android Test Report if: always() diff --git a/.github/workflows/e2e-degradation-nightly.yml b/.github/workflows/e2e-degradation-nightly.yml new file mode 100644 index 0000000000..9d2efb2793 --- /dev/null +++ b/.github/workflows/e2e-degradation-nightly.yml @@ -0,0 +1,214 @@ +# Nightly E2E tests under degraded network conditions (DDIL testing) +# Runs a matrix of network profiles to validate app behavior under various conditions + +name: E2E Degradation Tests (Nightly) + +on: + schedule: + # Run at 2 AM UTC every day + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + profiles: + description: 'Network profiles to test (comma-separated: slow_3g,edge_2g,satellite)' + required: false + default: 'slow_3g,edge_2g' + type: string + platforms: + description: 'Platforms to test (ios,android,both)' + required: false + default: 'ios' + type: choice + options: + - ios + - android + - both + +env: + MOBILE_VERSION: ${{ github.ref }} + +jobs: + # Parse input profiles into matrix + setup-matrix: + runs-on: ubuntu-22.04 + outputs: + profiles: ${{ steps.parse.outputs.profiles }} + run_ios: ${{ steps.parse.outputs.run_ios }} + run_android: ${{ steps.parse.outputs.run_android }} + steps: + - name: Parse profiles and platforms + id: parse + run: | + # Default profiles for scheduled runs + PROFILES="${{ github.event.inputs.profiles || 'slow_3g,edge_2g' }}" + + # Convert comma-separated to JSON array + PROFILES_JSON=$(echo "$PROFILES" | jq -R 'split(",") | map(gsub("^\\s+|\\s+$";""))' -c) + echo "profiles=$PROFILES_JSON" >> $GITHUB_OUTPUT + + # Determine platforms + PLATFORMS="${{ github.event.inputs.platforms || 'ios' }}" + if [ "$PLATFORMS" = "ios" ] || [ "$PLATFORMS" = "both" ]; then + echo "run_ios=true" >> $GITHUB_OUTPUT + else + echo "run_ios=false" >> $GITHUB_OUTPUT + fi + + if [ "$PLATFORMS" = "android" ] || [ "$PLATFORMS" = "both" ]; then + echo "run_android=true" >> $GITHUB_OUTPUT + else + echo "run_android=false" >> $GITHUB_OUTPUT + fi + + echo "Network profiles to test: $PROFILES_JSON" + echo "Platforms: $PLATFORMS" + + # Build iOS simulator app once for all profiles + build-ios-simulator: + if: ${{ needs.setup-matrix.outputs.run_ios == 'true' }} + runs-on: macos-26 + needs: setup-matrix + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Prepare iOS Build + uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'false' + + - name: Set .env with RUNNING_E2E=true + run: echo "RUNNING_E2E=true" > .env + + - name: Build iOS Simulator + env: + TAG: "${{ github.sha }}" + GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}" + run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true + working-directory: ./fastlane + + - name: Upload iOS Simulator Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ios-build-simulator-${{ github.run_id }} + path: Mattermost-simulator-*.app.zip + + # Build Android APK once for all profiles + build-android-apk: + if: ${{ needs.setup-matrix.outputs.run_android == 'true' }} + runs-on: ubuntu-latest-8-cores + needs: setup-matrix + env: + ORG_GRADLE_PROJECT_jvmargs: -Xmx8g + steps: + - name: Prune Docker to free up space + run: docker system prune -af + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Prepare Android Build + uses: ./.github/actions/prepare-android-build + env: + STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}" + STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}" + STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}" + MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}" + + - name: Install Dependencies + run: sudo apt-get clean && sudo apt-get update && sudo apt-get install -y default-jdk + + - name: Detox build + run: | + cd detox + npm install + npm install -g detox-cli + npm run e2e:android-inject-settings + npm run e2e:android-build + + - name: Upload Android Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: android-build-files-${{ github.run_id }} + path: "android/app/build/**/*" + + # Run iOS tests for each network profile + ios-degradation-tests: + if: ${{ needs.setup-matrix.outputs.run_ios == 'true' }} + needs: + - setup-matrix + - build-ios-simulator + strategy: + fail-fast: false + matrix: + profile: ${{ fromJSON(needs.setup-matrix.outputs.profiles) }} + name: iOS - ${{ matrix.profile }} + uses: ./.github/workflows/e2e-ios-template.yml + with: + run-type: "Nightly-Degradation" + MOBILE_VERSION: ${{ github.sha }} + network_profile: ${{ matrix.profile }} + record_tests_in_zephyr: 'false' + testcase_failure_fatal: false + secrets: inherit + + # Run Android tests for each network profile + android-degradation-tests: + if: ${{ needs.setup-matrix.outputs.run_android == 'true' }} + needs: + - setup-matrix + - build-android-apk + strategy: + fail-fast: false + matrix: + profile: ${{ fromJSON(needs.setup-matrix.outputs.profiles) }} + name: Android - ${{ matrix.profile }} + uses: ./.github/workflows/e2e-android-template.yml + with: + run-android-tests: true + run-type: "Nightly-Degradation" + MOBILE_VERSION: ${{ github.sha }} + network_profile: ${{ matrix.profile }} + record_tests_in_zephyr: 'false' + testcase_failure_fatal: false + secrets: inherit + + # Aggregate results and send notification + summary: + runs-on: ubuntu-22.04 + needs: + - setup-matrix + - ios-degradation-tests + - android-degradation-tests + if: always() + steps: + - name: Generate Summary + run: | + echo "# DDIL/Degradation Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Network Profiles Tested" >> $GITHUB_STEP_SUMMARY + echo '${{ needs.setup-matrix.outputs.profiles }}' | jq -r '.[]' | while read -r profile; do + echo "- $profile" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + + echo "## iOS Results" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.setup-matrix.outputs.run_ios }}" == "true" ]; then + echo "Status: ${{ needs.ios-degradation-tests.result }}" >> $GITHUB_STEP_SUMMARY + else + echo "Skipped" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "## Android Results" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.setup-matrix.outputs.run_android }}" == "true" ]; then + echo "Status: ${{ needs.android-degradation-tests.result }}" >> $GITHUB_STEP_SUMMARY + else + echo "Skipped" >> $GITHUB_STEP_SUMMARY + fi + + - name: Send Notification on Failure + if: ${{ failure() }} + run: | + echo "Degradation tests had failures - notification would be sent here" + # Add webhook notification if needed diff --git a/.github/workflows/e2e-degradation-weekly.yml b/.github/workflows/e2e-degradation-weekly.yml new file mode 100644 index 0000000000..5a10ea9c59 --- /dev/null +++ b/.github/workflows/e2e-degradation-weekly.yml @@ -0,0 +1,167 @@ +# Weekly E2E tests with flapping/intermittent network simulation +# Tests reconnection handling, message queue recovery, and race conditions +# during network state transitions + +name: E2E Degradation Tests (Weekly - Flapping) + +on: + schedule: + # Run at 3 AM UTC every Sunday + - cron: '0 3 * * 0' + workflow_dispatch: + inputs: + platforms: + description: 'Platforms to test' + required: false + default: 'ios' + type: choice + options: + - ios + - android + - both + +env: + MOBILE_VERSION: ${{ github.ref }} + +jobs: + setup: + runs-on: ubuntu-22.04 + outputs: + run_ios: ${{ steps.parse.outputs.run_ios }} + run_android: ${{ steps.parse.outputs.run_android }} + steps: + - name: Parse platforms + id: parse + run: | + PLATFORMS="${{ github.event.inputs.platforms || 'ios' }}" + if [ "$PLATFORMS" = "ios" ] || [ "$PLATFORMS" = "both" ]; then + echo "run_ios=true" >> $GITHUB_OUTPUT + else + echo "run_ios=false" >> $GITHUB_OUTPUT + fi + if [ "$PLATFORMS" = "android" ] || [ "$PLATFORMS" = "both" ]; then + echo "run_android=true" >> $GITHUB_OUTPUT + else + echo "run_android=false" >> $GITHUB_OUTPUT + fi + + build-ios-simulator: + if: ${{ needs.setup.outputs.run_ios == 'true' }} + runs-on: macos-26 + needs: setup + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Prepare iOS Build + uses: ./.github/actions/prepare-ios-build + with: + intune-enabled: 'false' + + - name: Set .env with RUNNING_E2E=true + run: echo "RUNNING_E2E=true" > .env + + - name: Build iOS Simulator + env: + TAG: "${{ github.sha }}" + GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}" + run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true + working-directory: ./fastlane + + - name: Upload iOS Simulator Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ios-build-simulator-${{ github.run_id }} + path: Mattermost-simulator-*.app.zip + + build-android-apk: + if: ${{ needs.setup.outputs.run_android == 'true' }} + runs-on: ubuntu-latest-8-cores + needs: setup + env: + ORG_GRADLE_PROJECT_jvmargs: -Xmx8g + steps: + - name: Prune Docker to free up space + run: docker system prune -af + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Prepare Android Build + uses: ./.github/actions/prepare-android-build + env: + STORE_FILE: "${{ secrets.MM_MOBILE_STORE_FILE }}" + STORE_ALIAS: "${{ secrets.MM_MOBILE_STORE_ALIAS }}" + STORE_PASSWORD: "${{ secrets.MM_MOBILE_STORE_PASSWORD }}" + MATTERMOST_BUILD_GH_TOKEN: "${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}" + + - name: Install Dependencies + run: sudo apt-get clean && sudo apt-get update && sudo apt-get install -y default-jdk + + - name: Detox build + run: | + cd detox + npm install + npm install -g detox-cli + npm run e2e:android-inject-settings + npm run e2e:android-build + + - name: Upload Android Build + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: android-build-files-${{ github.run_id }} + path: "android/app/build/**/*" + + # Flapping tests - intermittent connectivity + ios-flapping-tests: + if: ${{ needs.setup.outputs.run_ios == 'true' }} + needs: + - setup + - build-ios-simulator + name: iOS - flapping (intermittent) + uses: ./.github/workflows/e2e-ios-template.yml + with: + run-type: "Weekly-Flapping" + MOBILE_VERSION: ${{ github.sha }} + network_profile: flapping + record_tests_in_zephyr: 'false' + testcase_failure_fatal: false + secrets: inherit + + android-flapping-tests: + if: ${{ needs.setup.outputs.run_android == 'true' }} + needs: + - setup + - build-android-apk + name: Android - flapping (intermittent) + uses: ./.github/workflows/e2e-android-template.yml + with: + run-android-tests: true + run-type: "Weekly-Flapping" + MOBILE_VERSION: ${{ github.sha }} + network_profile: flapping + record_tests_in_zephyr: 'false' + testcase_failure_fatal: false + secrets: inherit + + summary: + runs-on: ubuntu-22.04 + needs: + - setup + - ios-flapping-tests + - android-flapping-tests + if: always() + steps: + - name: Generate Summary + run: | + echo "# Weekly Flapping Network Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Tests run with **flapping** profile (intermittent connectivity simulation)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Pattern: connected(30s) → disconnected(5s) → slow_3g(30s) → disconnected(3s) → repeat" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Results" >> $GITHUB_STEP_SUMMARY + echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY + echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| iOS | ${{ needs.ios-flapping-tests.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Android | ${{ needs.android-flapping-tests.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/e2e-ios-template.yml b/.github/workflows/e2e-ios-template.yml index 63346be8be..ec7e818ca0 100644 --- a/.github/workflows/e2e-ios-template.yml +++ b/.github/workflows/e2e-ios-template.yml @@ -45,10 +45,15 @@ on: type: string default: "iOS 26.2" low_bandwidth_mode: - description: "Enable low bandwidth mode" + description: "Enable low bandwidth mode (legacy - prefer network_profile)" required: false type: boolean default: false + network_profile: + description: "Network degradation profile (lte, slow_3g, edge_2g, satellite, flapping)" + required: false + type: string + default: "" outputs: STATUS: value: ${{ jobs.generate-report.outputs.STATUS }} @@ -124,7 +129,7 @@ jobs: name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }} runs-on: macos-15 continue-on-error: true - timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }} + timeout-minutes: ${{ (inputs.low_bandwidth_mode || inputs.network_profile != '') && 240 || 180 }} env: IOS: true needs: @@ -158,14 +163,15 @@ jobs: # delete zip file rm -f mobile-artifacts/*.zip - - name: Prepare Low Bandwidth Environment + - name: Prepare Degraded Network Environment id: prepare-low-bandwidth uses: ./.github/actions/prepare-low-bandwidth - if: ${{ inputs.low_bandwidth_mode }} + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} with: test_server_url: ${{ env.SITE_1_URL }} device_name: ${{ env.DEVICE_NAME }} - # all these value should be configurable + network_profile: ${{ inputs.network_profile }} + # Fallback values for legacy low_bandwidth_mode (when no profile specified) download_speed: "3300" upload_speed: "3300" latency: "500" @@ -250,7 +256,7 @@ jobs: echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV - name: Start Proxy - if: ${{ inputs.low_bandwidth_mode }} + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} id: start-proxy uses: ./.github/actions/start-proxy with: @@ -333,7 +339,8 @@ jobs: DETOX_LOGLEVEL: "debug" DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }} DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }} - LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }} + LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} + NETWORK_PROFILE: ${{ inputs.network_profile }} - name: Cleanup Simulator State if: always() @@ -356,7 +363,7 @@ jobs: echo "✅ Simulator cleanup complete" - name: reset network settings - if: ${{ inputs.low_bandwidth_mode || failure() }} + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' || failure() }} run: | networksetup -setwebproxystate Ethernet "off" networksetup -setsecurewebproxystate Ethernet "off" @@ -367,7 +374,7 @@ jobs: sleep 5; - name: Upload mitmdump Flow Output - if: ${{ inputs.low_bandwidth_mode }} + if: ${{ inputs.low_bandwidth_mode || inputs.network_profile != '' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ios-mitmdump-flow-output-${{ needs.generate-specs.outputs.workflow_hash }}-${{ matrix.runId }} diff --git a/detox/e2e/support/utils/index.ts b/detox/e2e/support/utils/index.ts index 892da83782..1f6ebeb49a 100644 --- a/detox/e2e/support/utils/index.ts +++ b/detox/e2e/support/utils/index.ts @@ -4,8 +4,11 @@ import {adminEmail, adminPassword, adminUsername} from '@support/test_config'; import {v4 as uuidv4} from 'uuid'; +import {getTimeoutMultiplier} from './network_profiles'; + export * from './email'; export * from './detoxhelpers'; +export * from './network_profiles'; /** * Explicit `wait` should not normally used but made available for special cases. @@ -63,8 +66,10 @@ export const getAdminAccount = () => { }; }; -const SECOND = 1000 * (process.env.LOW_BANDWIDTH_MODE === 'true' ? 5 : 1); -const MINUTE = 60 * 1000; +// Get timeout multiplier from network profile (supports both new NETWORK_PROFILE and legacy LOW_BANDWIDTH_MODE) +const TIMEOUT_MULTIPLIER = getTimeoutMultiplier(); +const SECOND = 1000 * TIMEOUT_MULTIPLIER; +const MINUTE = 60 * 1000 * TIMEOUT_MULTIPLIER; export const timeouts = { HALF_SEC: SECOND / 2, diff --git a/detox/e2e/support/utils/network_profiles.ts b/detox/e2e/support/utils/network_profiles.ts new file mode 100644 index 0000000000..42ed3eb372 --- /dev/null +++ b/detox/e2e/support/utils/network_profiles.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * Network degradation profiles for DDIL testing. + * These match the profiles defined in .github/actions/bandwidth-throttling/profiles.yml + */ + +export type NetworkProfile = 'lte' | 'slow_3g' | 'edge_2g' | 'satellite' | 'flapping' | 'custom'; + +export interface NetworkProfileConfig { + name: NetworkProfile; + description: string; + downloadKbps: number; + uploadKbps: number; + latencyMs: number; + packetLossPercent: number; + timeoutMultiplier: number; +} + +export const NETWORK_PROFILES: Record = { + lte: { + name: 'lte', + description: 'Good LTE/4G connection - baseline for comparison', + downloadKbps: 10000, + uploadKbps: 5000, + latencyMs: 30, + packetLossPercent: 0, + timeoutMultiplier: 1, + }, + slow_3g: { + name: 'slow_3g', + description: 'Slow 3G - rural or congested network', + downloadKbps: 400, + uploadKbps: 128, + latencyMs: 300, + packetLossPercent: 2, + timeoutMultiplier: 3, + }, + edge_2g: { + name: 'edge_2g', + description: 'Edge/2G - minimal cellular connectivity', + downloadKbps: 50, + uploadKbps: 25, + latencyMs: 500, + packetLossPercent: 5, + timeoutMultiplier: 10, + }, + satellite: { + name: 'satellite', + description: 'GEO Satellite - high latency connection', + downloadKbps: 1000, + uploadKbps: 256, + latencyMs: 700, + packetLossPercent: 1, + timeoutMultiplier: 5, + }, + flapping: { + name: 'flapping', + description: 'Intermittent connectivity - connection drops and recovers', + downloadKbps: 1000, + uploadKbps: 256, + latencyMs: 200, + packetLossPercent: 0, + timeoutMultiplier: 5, + }, + custom: { + name: 'custom', + description: 'Custom profile with manual settings', + downloadKbps: 3300, + uploadKbps: 3300, + latencyMs: 500, + packetLossPercent: 0, + timeoutMultiplier: 1, + }, +}; + +/** + * Get the current network profile from environment variables. + * Falls back to 'lte' (baseline) if not set. + */ +export function getCurrentNetworkProfile(): NetworkProfile { + const envProfile = process.env.NETWORK_PROFILE as NetworkProfile; + if (envProfile && NETWORK_PROFILES[envProfile]) { + return envProfile; + } + + // Backward compatibility with LOW_BANDWIDTH_MODE + // Legacy mode uses 3300/3300/500 settings from prepare-low-bandwidth action + // Map to slow_3g for timeout multiplier (3x) - close enough for test timing purposes + if (process.env.LOW_BANDWIDTH_MODE === 'true') { + return 'slow_3g'; + } + + return 'lte'; +} + +/** + * Get the timeout multiplier for the current network profile. + * Use this to scale test timeouts based on network conditions. + */ +export function getTimeoutMultiplier(): number { + const profile = getCurrentNetworkProfile(); + return NETWORK_PROFILES[profile].timeoutMultiplier; +} + +/** + * Get the full configuration for the current network profile. + */ +export function getCurrentNetworkConfig(): NetworkProfileConfig { + const profile = getCurrentNetworkProfile(); + return NETWORK_PROFILES[profile]; +} + +/** + * Check if we're running under degraded network conditions. + */ +export function isDegradedNetwork(): boolean { + const profile = getCurrentNetworkProfile(); + return profile !== 'lte'; +} + +/** + * Check if we're running under severely degraded network conditions. + * This is useful for skipping tests that are not meaningful under extreme degradation. + */ +export function isSeverelyDegradedNetwork(): boolean { + const profile = getCurrentNetworkProfile(); + return profile === 'edge_2g' || profile === 'flapping'; +} + +/** + * Get a formatted string with current network profile information. + * Useful at the start of test runs for debugging. + */ +export function getNetworkProfileInfo(): string { + const config = getCurrentNetworkConfig(); + return [ + '=== Network Profile ===', + `Profile: ${config.name}`, + `Description: ${config.description}`, + `Download: ${config.downloadKbps} Kbps`, + `Upload: ${config.uploadKbps} Kbps`, + `Latency: ${config.latencyMs} ms`, + `Packet Loss: ${config.packetLossPercent}%`, + `Timeout Multiplier: ${config.timeoutMultiplier}x`, + '=======================', + ].join('\n'); +} diff --git a/detox/e2e/test/degradation/README.md b/detox/e2e/test/degradation/README.md new file mode 100644 index 0000000000..baebfae254 --- /dev/null +++ b/detox/e2e/test/degradation/README.md @@ -0,0 +1,55 @@ +# Degradation Tests (DDIL Testing) + +This directory contains E2E tests specifically designed to validate app behavior under degraded network conditions (Disconnected, Degraded, Intermittent, and Low-bandwidth environments). + +## Purpose + +These tests focus on behaviors that are critical under poor network conditions: +- Message delivery and eventual consistency +- Reconnection handling +- Offline queue behavior +- UI feedback during slow operations +- Error handling and recovery + +## Network Profiles + +Tests are designed to run under various network profiles defined in `.github/actions/bandwidth-throttling/profiles.yml`: + +| Profile | Download | Latency | Packet Loss | Use Case | +|---------|----------|---------|-------------|----------| +| `slow_3g` | 400 Kbps | 300ms | 2% | Rural/congested areas | +| `edge_2g` | 50 Kbps | 500ms | 5% | Edge coverage | +| `satellite` | 1 Mbps | 700ms | 1% | High-latency satellite | +| `flapping` | varies | varies | varies | Intermittent connectivity | + +## Running Degradation Tests + +### Locally (with network simulation) +```bash +# Use Charles Proxy or similar to throttle network +cd detox +NETWORK_PROFILE=slow_3g npm run e2e:ios-test -- --testPathPattern=degradation +``` + +### In CI +The nightly workflow runs these tests automatically under multiple profiles: +```bash +# Trigger manually via GitHub Actions workflow_dispatch +gh workflow run e2e-degradation-nightly.yml -f profiles=slow_3g,edge_2g -f platforms=ios +``` + +## Test Guidelines + +1. **Use longer timeouts**: Import `getTimeoutMultiplier()` from `@support/utils` and multiply standard timeouts +2. **Expect retries**: Operations may need multiple attempts +3. **Check eventual consistency**: Verify state after allowing time for sync +4. **Validate UI feedback**: Ensure loading states and connection banners appear appropriately +5. **Don't assert timing**: Under degradation, exact timing is unpredictable + +## Expanding Test Coverage + +To add a test to degradation testing: +1. Place it in this `degradation/` directory, or +2. Tag existing tests with `@degradation` in the test name (future feature) + +Start with critical user journeys and expand based on field reports of issues under poor network conditions. diff --git a/detox/e2e/test/degradation/connection_recovery.e2e.ts b/detox/e2e/test/degradation/connection_recovery.e2e.ts new file mode 100644 index 0000000000..0efa00c000 --- /dev/null +++ b/detox/e2e/test/degradation/connection_recovery.e2e.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import { + Post, + Setup, +} from '@support/server_api'; +import { + serverOneUrl, + siteOneUrl, +} from '@support/test_config'; +import { + ChannelScreen, + ChannelListScreen, + HomeScreen, + LoginScreen, + ServerScreen, +} from '@support/ui/screen'; +import { + getRandomId, + timeouts, + wait, + getNetworkProfileInfo, + getCurrentNetworkProfile, +} from '@support/utils'; +import {expect} from 'detox'; + +// Cap timeouts to prevent extremely long waits (max 3 minutes) +// Note: timeouts.* are already scaled by getTimeoutMultiplier() in index.ts +const MAX_TIMEOUT = 3 * 60 * 1000; +const cappedTimeout = (timeout: number) => Math.min(timeout, MAX_TIMEOUT); + +describe('Degradation - Connection Recovery', () => { + const serverOneDisplayName = 'Server 1'; + const channelsCategory = 'channels'; + let testChannel: any; + + beforeAll(async () => { + // Log network profile for debugging - visible in test output + // eslint-disable-next-line no-console + console.log(getNetworkProfileInfo()); + + const {channel, user} = await Setup.apiInit(siteOneUrl); + testChannel = channel; + + // # Log in to server + await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); + await LoginScreen.login(user); + }); + + beforeEach(async () => { + // * Verify on channel list screen + await ChannelListScreen.toBeVisible(); + }); + + afterAll(async () => { + // # Log out + await HomeScreen.logout(); + }); + + it('DDIL-T010 - should recover after app backgrounding', async () => { + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Send a message before backgrounding + const beforeMessage = `Before background ${getRandomId()}`; + await ChannelScreen.postMessage(beforeMessage); + + // * Verify message was sent + const {post: beforePost} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(beforePost.message).toBe(beforeMessage); + + // # Background the app + await device.sendToBackground(); + await wait(timeouts.TEN_SEC); + + // # Bring app back to foreground + await device.launchApp({newInstance: false}); + + // * Verify channel is still visible + await expect(ChannelScreen.headerTitle).toHaveText(testChannel.display_name); + + // # Send a message after returning from background + const afterMessage = `After background ${getRandomId()}`; + await ChannelScreen.postMessage(afterMessage); + + // * Allow time for sync under degraded conditions + await wait(cappedTimeout(timeouts.TEN_SEC)); + + // * Verify message was sent + const {post: afterPost} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(afterPost.message).toBe(afterMessage); + + // # Go back to channel list screen + await ChannelScreen.back(); + }); + + it('DDIL-T011 - should receive messages sent while app was backgrounded', async () => { + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Background the app + await device.sendToBackground(); + + // # Send a message via API while app is backgrounded + const apiMessage = `API message while backgrounded ${getRandomId()}`; + await Post.apiCreatePost(siteOneUrl, { + channel_id: testChannel.id, + message: apiMessage, + }); + + // # Wait a bit then bring app back + await wait(timeouts.FOUR_SEC); + await device.launchApp({newInstance: false}); + + // * Allow time for sync under degraded conditions + await wait(cappedTimeout(timeouts.TEN_SEC)); + + // * Verify the message appears in the channel + const {post} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + const {postListPostItem} = ChannelScreen.getPostListPostItem(post.id, apiMessage); + + await waitFor(postListPostItem).toBeVisible().withTimeout(cappedTimeout(timeouts.ONE_MIN)); + + // # Go back to channel list screen + await ChannelScreen.back(); + }); + + it('DDIL-T012 - should handle rapid navigation under degraded network', async () => { + // Rapid navigation tests the app's ability to cancel pending requests + // and handle state transitions under slow network + + // # Navigate into channel + await ChannelScreen.open(channelsCategory, testChannel.name); + await expect(ChannelScreen.headerTitle).toBeVisible(); + + // # Quick back navigation + await ChannelScreen.back(); + await ChannelListScreen.toBeVisible(); + + // # Navigate back into channel + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Try to type and send a message + const message = `Rapid nav test ${getRandomId()}`; + await ChannelScreen.postMessage(message); + + // * Verify message sent successfully + await wait(cappedTimeout(timeouts.TEN_SEC)); + const {post} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(post.message).toBe(message); + + // # Go back + await ChannelScreen.back(); + }); + + it('DDIL-T013 - should deliver messages under extended degraded conditions', async () => { + // This test validates basic message delivery under prolonged degraded conditions + + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Send a message to verify functionality under degraded network + const message = `Extended degradation test ${getRandomId()} - ${getCurrentNetworkProfile()}`; + await ChannelScreen.postMessage(message); + + // * Allow extended time for delivery under degraded network + await wait(cappedTimeout(timeouts.HALF_MIN)); + + // * Verify message was delivered + const {post} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(post.message).toBe(message); + + // # Go back to channel list screen + await ChannelScreen.back(); + }); +}); diff --git a/detox/e2e/test/degradation/message_delivery.e2e.ts b/detox/e2e/test/degradation/message_delivery.e2e.ts new file mode 100644 index 0000000000..51ddcd85e6 --- /dev/null +++ b/detox/e2e/test/degradation/message_delivery.e2e.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import { + Post, + Setup, +} from '@support/server_api'; +import { + serverOneUrl, + siteOneUrl, +} from '@support/test_config'; +import { + ChannelScreen, + ChannelListScreen, + HomeScreen, + LoginScreen, + ServerScreen, +} from '@support/ui/screen'; +import { + getRandomId, + timeouts, + wait, + getCurrentNetworkProfile, + getNetworkProfileInfo, + isDegradedNetwork, +} from '@support/utils'; +import {expect} from 'detox'; + +// Cap timeouts to prevent extremely long waits (max 3 minutes) +// Note: timeouts.* are already scaled by getTimeoutMultiplier() in index.ts +const MAX_TIMEOUT = 3 * 60 * 1000; +const cappedTimeout = (timeout: number) => Math.min(timeout, MAX_TIMEOUT); + +describe('Degradation - Message Delivery', () => { + const serverOneDisplayName = 'Server 1'; + const channelsCategory = 'channels'; + let testChannel: any; + + beforeAll(async () => { + // Log network profile for debugging - visible in test output + // eslint-disable-next-line no-console + console.log(getNetworkProfileInfo()); + + const {channel, user} = await Setup.apiInit(siteOneUrl); + testChannel = channel; + + // # Log in to server + await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); + await LoginScreen.login(user); + }); + + beforeEach(async () => { + // * Verify on channel list screen + await ChannelListScreen.toBeVisible(); + }); + + afterAll(async () => { + // # Log out + await HomeScreen.logout(); + }); + + it('DDIL-T001 - should eventually deliver message under degraded network', async () => { + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Create and send a message + const message = `Degradation test ${getRandomId()} - ${getCurrentNetworkProfile()}`; + await ChannelScreen.postInput.tap(); + await ChannelScreen.postInput.replaceText(message); + + // # Tap send button + await ChannelScreen.sendButton.tap(); + + // * Wait for message to appear in post list (with extended timeout for degraded network) + // Under degraded conditions, this may take significantly longer + await waitFor(ChannelScreen.postInput).not.toHaveValue(message).withTimeout(cappedTimeout(timeouts.ONE_MIN)); + + // * Verify message eventually appears (allow time for server round-trip) + await wait(cappedTimeout(timeouts.TEN_SEC)); + + const {post} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(post.message).toBe(message); + + // * Verify the message is visible in the UI + const {postListPostItem} = ChannelScreen.getPostListPostItem(post.id, message); + await expect(postListPostItem).toBeVisible(); + + // # Go back to channel list screen + await ChannelScreen.back(); + }); + + // This test requires degraded network to observe loading states + const degradedOnlyTest = isDegradedNetwork() ? it : it.skip; + degradedOnlyTest('DDIL-T002 - should show loading state for slow message send', async () => { + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + // # Create a message + const message = `Slow send test ${getRandomId()}`; + await ChannelScreen.postInput.tap(); + await ChannelScreen.postInput.replaceText(message); + + // # Tap send button + await ChannelScreen.sendButton.tap(); + + // * Under degraded network, the send button should be disabled while sending + // This validates the app provides appropriate feedback during slow operations + await expect(ChannelScreen.sendButtonDisabled).toBeVisible(); + + // * Wait for message to complete sending (input clears when sent) + await waitFor(ChannelScreen.postInput).toHaveText('').withTimeout(cappedTimeout(timeouts.TWO_MIN)); + + // * Verify message was delivered + const {post} = await Post.apiGetLastPostInChannel(siteOneUrl, testChannel.id); + expect(post.message).toBe(message); + + // # Go back to channel list screen + await ChannelScreen.back(); + }); + + it('DDIL-T003 - should send multiple messages in order', async () => { + // # Open a channel screen + await ChannelScreen.open(channelsCategory, testChannel.name); + + const messages = [ + `First message ${getRandomId()}`, + `Second message ${getRandomId()}`, + `Third message ${getRandomId()}`, + ]; + + // # Send multiple messages in sequence (sequential await is intentional for test ordering) + for (const message of messages) { + // eslint-disable-next-line no-await-in-loop + await ChannelScreen.postMessage(message); + // eslint-disable-next-line no-await-in-loop + await wait(timeouts.ONE_SEC); + } + + // * Allow time for all messages to sync + await wait(cappedTimeout(timeouts.TEN_SEC)); + + // * Verify all messages arrived in order via API + // apiGetPostsInChannel returns {posts: Post[]} where posts is an ordered array (newest first) + const {posts} = await Post.apiGetPostsInChannel(siteOneUrl, testChannel.id); + + // Filter to find only our test messages (by matching the unique message content) + const ourPosts = posts. + map((p: {message: string}) => p.message). + filter((msg: string) => messages.some((m) => msg === m)); + + // Verify we found all our messages + expect(ourPosts.length).toBe(messages.length); + + // Posts are ordered newest-first, so reverse for chronological order + const chronologicalPosts = ourPosts.reverse(); + + for (let i = 0; i < messages.length; i++) { + expect(chronologicalPosts[i]).toBe(messages[i]); + } + + // # Go back to channel list screen + await ChannelScreen.back(); + }); + + it('DDIL-T004 - should handle channel switch during slow network', async () => { + // # Open first channel + await ChannelScreen.open(channelsCategory, testChannel.name); + + // * Verify channel loads + await expect(ChannelScreen.headerTitle).toHaveText(testChannel.display_name); + + // # Go back and verify channel list is responsive + await ChannelScreen.back(); + await ChannelListScreen.toBeVisible(); + + // # Open channel again - should work even under degradation + await ChannelScreen.open(channelsCategory, testChannel.name); + await expect(ChannelScreen.headerTitle).toHaveText(testChannel.display_name); + + // # Go back + await ChannelScreen.back(); + }); +});