Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/actions/build-android/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,35 @@ 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"

- name: Set ANDROID_NDK_HOME
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

Expand Down
48 changes: 48 additions & 0 deletions .github/scripts/run-instrumented-tests.sh
Original file line number Diff line number Diff line change
@@ -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
56 changes: 55 additions & 1 deletion .github/workflows/build-debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
Expand Down Expand Up @@ -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
Expand All @@ -107,23 +111,64 @@ 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
arch: x86_64
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()
Expand All @@ -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
178 changes: 178 additions & 0 deletions app/src/androidTest/java/io/netbird/client/SetupKeyAuthTest.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>The setup key is read from an instrumentation runner argument so CI can
* inject it as a secret without baking it into the APK:
* <pre>
* ./gradlew connectedDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.setupKey=&lt;UUID&gt;
* </pre>
*
* <p>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<MainActivity> activityRule =
new ActivityTestRule<>(MainActivity.class, true, true);

@Test
public void loginWithSetupKeyViaUi() throws Exception {
Bundle args = InstrumentationRegistry.getArguments();
String setupKey = args.getString("setupKey");

assertNotNull("setupKey instrumentation argument is required", setupKey);
assertTrue("setupKey must not be blank", !setupKey.trim().isEmpty());

UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
device.waitForIdle();

// Try the navigation up to 2 times — on first launch the MainActivity
// pushes firstInstallFragment after onCreate, which can race with our
// navigate() call from the test thread.
UiObject2 setupKeyLabel = null;
for (int attempt = 1; attempt <= 2 && setupKeyLabel == null; attempt++) {
Log.i(TAG, "navigateToChangeServer attempt " + attempt);
navigateToChangeServer();
dismissConfirmChangeServerDialog(device);
setupKeyLabel = device.wait(
Until.findObject(By.res(PACKAGE, "text_setup_key_label")), UI_TIMEOUT_MS);
}
if (setupKeyLabel == null) {
dumpScreenshot(device, "navigation-failed");
fail("text_setup_key_label not found after 2 navigation attempts");
}
setupKeyLabel.click();

UiObject2 setupKeyField = device.wait(
Until.findObject(By.res(PACKAGE, "edit_text_setup_key")), UI_TIMEOUT_MS);
assertNotNull("edit_text_setup_key must be present", setupKeyField);
setupKeyField.setText(setupKey.trim());

// "Use NetBird" submits with the app's default management URL.
UiObject2 submit = device.wait(
Until.findObject(By.res(PACKAGE, "btn_use_netbird")), UI_TIMEOUT_MS);
assertNotNull("btn_use_netbird must be present", submit);
submit.click();

// Either the success dialog ("btn_close") shows up, or the form re-enables
// itself with an error.
long deadline = System.currentTimeMillis() + LOGIN_TIMEOUT_MS;
while (System.currentTimeMillis() < deadline) {
UiObject2 closeBtn = device.findObject(By.res(PACKAGE, "btn_close"));
if (closeBtn != null) {
Log.i(TAG, "Setup-key login succeeded");
closeBtn.click();
return;
}
// If the "Change server" button is enabled again, the request came
// back with an error.
UiObject2 submitAgain = device.findObject(By.res(PACKAGE, "btn_change_server"));
if (submitAgain != null && submitAgain.isEnabled()) {
fail("Login failed: submit button re-enabled without success dialog");
}
Thread.sleep(200);
}
dumpScreenshot(device, "login-timeout");
fail("Login did not complete within " + (LOGIN_TIMEOUT_MS / 1000) + "s");
}

/**
* Skip the first-install teaser and jump straight to the "Change server" screen.
* {@code hideAlert=true} suppresses the "are you sure?" warning dialog so this
* is non-interactive.
*/
private void navigateToChangeServer() throws InterruptedException {
MainActivity activity = activityRule.getActivity();
assertNotNull("MainActivity must be available", activity);

activity.runOnUiThread(() -> {
View host = activity.findViewById(R.id.nav_host_fragment_content_main);
NavController nav = Navigation.findNavController(host);
Bundle bundle = new Bundle();
bundle.putBoolean(ChangeServerFragment.HideAlertBundleArg, true);
// Same nav options the FirstInstallFragment uses when the user taps
// its "change_server" link, so we land in the same place.
NavOptions opts = new NavOptions.Builder()
.setPopUpTo(R.id.firstInstallFragment, true)
.build();
nav.navigate(R.id.nav_change_server, bundle, opts);
});
// Let the fragment transaction commit before UiAutomator looks for views.
Thread.sleep(1500);
}

/**
* The Change Server fragment shows a "this will erase the local config"
* confirmation dialog whenever it opens — even when the caller passed
* {@code hideAlert=true}, because that arg is not currently honoured by
* the fragment. Tap Yes to dismiss so we can interact with the form.
*/
private static void dismissConfirmChangeServerDialog(UiDevice device) {
UiObject2 yes = device.wait(
Until.findObject(By.res(PACKAGE, "btn_yes")), UI_TIMEOUT_MS);
if (yes != null) {
Log.i(TAG, "Dismissing change-server confirmation dialog");
yes.click();
device.waitForIdle();
}
}

/**
* Take a screenshot via UiAutomator and write it into the test runner's
* working dir (cwd is /data/local/tmp/io.netbird.client.test on most
* devices, which `adb pull` can read).
*/
private static void dumpScreenshot(UiDevice device, String name) {
try {
File png = new File("/sdcard/Pictures/" + name + ".png");
//noinspection ResultOfMethodCallIgnored
png.getParentFile().mkdirs();
boolean ok = device.takeScreenshot(png);
Log.i(TAG, "Screenshot " + (ok ? "saved to " : "FAILED for ") + png);
} catch (Throwable t) {
Log.w(TAG, "Failed to dump screenshot: " + t.getMessage());
}
}
}
Loading