diff --git a/.github/actions/build-android/action.yml b/.github/actions/build-android/action.yml index a92c7efb..744839f3 100644 --- a/.github/actions/build-android/action.yml +++ b/.github/actions/build-android/action.yml @@ -49,8 +49,19 @@ runs: uses: actions/setup-go@v5 with: go-version-file: "netbird/go.mod" + cache-dependency-path: "netbird/go.sum" + + - name: Cache Android NDK + id: ndk-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to ${{ env.X }} + # in composite actions; the ubuntu-latest image pins it to this path. + path: /usr/local/lib/android/sdk/ndk/23.1.7779620 + key: ndk-23.1.7779620 - name: Setup NDK + if: steps.ndk-cache.outputs.cache-hit != 'true' shell: bash run: ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager --install "ndk;23.1.7779620" @@ -58,7 +69,15 @@ runs: shell: bash run: echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/23.1.7779620" >> $GITHUB_ENV + - name: Cache gomobile binary + id: gomobile-cache + uses: actions/cache@v4 + with: + path: ~/go/bin/gomobile + key: gomobile-v0.0.0-20251113184115-a159579294ab + - name: Install gomobile + if: steps.gomobile-cache.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20251113184115-a159579294ab diff --git a/.github/scripts/run-instrumented-tests.sh b/.github/scripts/run-instrumented-tests.sh new file mode 100755 index 00000000..21411b7b --- /dev/null +++ b/.github/scripts/run-instrumented-tests.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Runs connectedDebugAndroidTest while capturing the emulator screen and +# logcat in the background. screenrecord caps each clip at 180s, so we loop +# until the test finishes; logcat streams continuously to a file. Both end +# up in screen-recordings/ and are uploaded as an artifact from the workflow. +# +# Expects $INSTRUMENTATION_NB_SETUP_KEY in the environment. + +set +e + +mkdir -p screen-recordings +adb shell mkdir -p /sdcard/recordings + +adb logcat -c +adb logcat -v threadtime > screen-recordings/logcat.log 2>&1 & +LOGCAT_PID=$! + +# Sentinel must exist before the background loop starts; otherwise the first +# iteration sees no file and exits immediately. +touch /tmp/record_active +( + i=0 + while [ -f /tmp/record_active ]; do + seg=$(printf "seg_%03d.mp4" "$i") + echo "[record] starting $seg" + adb shell screenrecord --time-limit 180 --bit-rate 4000000 "/sdcard/recordings/$seg" + echo "[record] $seg exited" + i=$((i + 1)) + done + echo "[record] loop ended" +) & +REC_LOOP_PID=$! + +./gradlew --no-daemon connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest \ + -Pandroid.testInstrumentationRunnerArguments.setupKey="$INSTRUMENTATION_NB_SETUP_KEY" +TEST_EXIT=$? + +rm -f /tmp/record_active +adb shell pkill -SIGINT screenrecord 2>/dev/null || true +wait "$REC_LOOP_PID" 2>/dev/null || true +sleep 3 +adb pull /sdcard/recordings ./screen-recordings/ || true + +kill "$LOGCAT_PID" 2>/dev/null || true +wait "$LOGCAT_PID" 2>/dev/null || true + +exit $TEST_EXIT diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml index 936762e4..359d6cf2 100644 --- a/.github/workflows/build-debug.yml +++ b/.github/workflows/build-debug.yml @@ -5,6 +5,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read @@ -87,7 +88,10 @@ jobs: instrumented-tests: needs: build-debug + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 30 + environment: instrumentation-test-secrets steps: - name: Checkout repository uses: actions/checkout@v4 @@ -107,14 +111,53 @@ jobs: name: netbird-aar path: gomobile + - name: Verify required secrets + env: + INSTRUMENTATION_NB_SETUP_KEY: ${{ secrets.INSTRUMENTATION_NB_SETUP_KEY }} + run: | + if [ -z "$INSTRUMENTATION_NB_SETUP_KEY" ]; then + echo "::error::INSTRUMENTATION_NB_SETUP_KEY repository secret is not configured" + exit 1 + fi + - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: AVD cache + id: avd-cache + uses: actions/cache@v4 + with: + # ANDROID_HOME is set by the runner image but not visible to + # ${{ env.X }} at expression-eval time; hardcoded to the path on + # ubuntu-latest. + path: | + ~/.android/avd/* + ~/.android/adb* + /usr/local/lib/android/sdk/system-images/android-30/google_apis/x86_64 + key: avd-api30-google_apis-x86_64-pixel_3a-v1 + + - name: Create AVD snapshot + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + target: google_apis + arch: x86_64 + profile: pixel_3a + disk-size: 4096M + heap-size: 512M + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: echo "Generated AVD snapshot for caching." + - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 + env: + INSTRUMENTATION_NB_SETUP_KEY: ${{ secrets.INSTRUMENTATION_NB_SETUP_KEY }} with: api-level: 30 target: google_apis @@ -122,8 +165,10 @@ jobs: profile: pixel_3a disk-size: 4096M heap-size: 512M + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim disable-animations: true - script: ./gradlew connectedDebugAndroidTest --no-daemon -Pandroid.testInstrumentationRunnerArguments.notClass=io.netbird.client.NetworkConnectivityStressTest + script: bash .github/scripts/run-instrumented-tests.sh - name: Upload test results if: always() @@ -134,3 +179,12 @@ jobs: app/build/reports/androidTests/ tool/build/reports/androidTests/ retention-days: 3 + + - name: Upload screen recordings + if: always() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-screen-recordings + path: screen-recordings/ + if-no-files-found: warn + retention-days: 3 diff --git a/app/src/androidTest/java/io/netbird/client/SetupKeyAuthTest.java b/app/src/androidTest/java/io/netbird/client/SetupKeyAuthTest.java new file mode 100644 index 00000000..bb973969 --- /dev/null +++ b/app/src/androidTest/java/io/netbird/client/SetupKeyAuthTest.java @@ -0,0 +1,178 @@ +package io.netbird.client; + +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import java.io.File; + +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.Navigation; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.Until; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.netbird.client.ui.server.ChangeServerFragment; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Drives the "Change server" UI to authenticate against the default NetBird + * management server with a setup key — exactly the flow a user would use, but + * automated. + * + *
The setup key is read from an instrumentation runner argument so CI can + * inject it as a secret without baking it into the APK: + *
+ * ./gradlew connectedDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.setupKey=<UUID> + *+ * + *
The test navigates straight to {@code nav_change_server} (skipping the
+ * first-install teaser screen), fills the setup key and taps the
+ * "Use NetBird" button, which uses the management URL hard-coded in the app
+ * ({@code Preferences.defaultServer()}). Then it waits for the success dialog.
+ */
+@RunWith(AndroidJUnit4.class)
+public class SetupKeyAuthTest {
+
+ private static final String TAG = "NBSetupKeyAuthTest";
+ private static final String PACKAGE = "io.netbird.client";
+ private static final long UI_TIMEOUT_MS = 5_000;
+ private static final long LOGIN_TIMEOUT_MS = 15_000;
+
+ @SuppressWarnings("deprecation")
+ @Rule
+ public ActivityTestRule