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
38 changes: 37 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 @@ -85,9 +86,14 @@ jobs:
tool/build/reports/tests/
retention-days: 3

# Classic instrumented tests: everything outside io.netbird.client.e2e.
# They need no secrets and no external infrastructure, so they can run on
# every PR, forks included. The e2e package (production API, setup keys)
# runs from the private mobile-e2e repo instead.
instrumented-tests:
needs: build-debug
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -113,6 +119,34 @@ jobs:
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
with:
Expand All @@ -122,8 +156,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: ./gradlew --no-daemon connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notPackage=io.netbird.client.e2e

- name: Upload test results
if: always()
Expand Down
13 changes: 12 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ android {
versionName = rootProject.extra["appVersionName"] as String

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["timeout_msec"] = "3600000"
testInstrumentationRunnerArguments["timeout_msec"] = "300000"

// Pass setup keys from the environment to the e2e tests (a -P arg still overrides).
mapOf(
"setupKey" to "INSTRUMENTATION_NB_SETUP_KEY",
"exitNodeSetupKey" to "INSTRUMENTATION_EXIT_NODE_SETUP_KEY"
).forEach { (arg, envVar) ->
val value = System.getenv(envVar)
if (!value.isNullOrBlank()) {
testInstrumentationRunnerArguments[arg] = value
}
}
}

buildTypes {
Expand Down
2 changes: 1 addition & 1 deletion app/src/androidTest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ adb install -r -t app/build/outputs/apk/debug/app-debug.apk
adb install -r -t app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk

# 3. Run the test directly via adb
adb shell am instrument -w -e class io.netbird.client.NetworkConnectivityStressTest \
adb shell am instrument -w -e class io.netbird.client.e2e.NetworkConnectivityStressTest \
io.netbird.client.test/androidx.test.runner.AndroidJUnitRunner
```

Expand Down
128 changes: 128 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/DnsResolutionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import android.os.Bundle;
import android.util.Log;

import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;

/**
* DNS resolution test — the Android port of the Robot
* {@code client-tests.robot} case "Should resolve the domain and hostname".
*
* <p>With the tunnel up, the {@code dnstest} peer's internal name must resolve
* to its private address {@code 172.20.3.158} through the NetBird DNS. The
* original runs {@code dig <name>}; here we run a real {@code nslookup} on the
* device (via the shell, like the ping tests), so it exercises the device
* resolver / VpnService DNS exactly as a user's traffic would.
*
* <p>Mirrors the two original assertions:
* <ul>
* <li>FQDN resolves: {@code nslookup
* ip-172-20-3-158.eu-central-1.compute.internal} → 172.20.3.158;</li>
* <li>search-domain (unqualified) resolves: {@code nslookup ip-172-20-3-158}
* → 172.20.3.158.</li>
* </ul>
*
* <p>The original additionally checks the main system resolver is a NetBird
* {@code 100.x} address via {@code /etc/resolv.conf} / {@code resolvectl}. That
* is Linux-specific and not portable to Android (no {@code resolv.conf} in the
* usual sense; the VpnService owns DNS), so it is omitted — the resolution
* results above already prove tunnel DNS is in effect.
*
* <p>Only the setup key is injected:
* <pre>
* ./gradlew connectedDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.setupKey=&lt;UUID&gt;
* </pre>
*/
@RunWith(AndroidJUnit4.class)
public class DnsResolutionTest {

private static final String TAG = "NBDnsTest";

/** dnstest peer's internal hostname and its expected private address. */
private static final String PEER_FQDN = "ip-172-20-3-158.eu-central-1.compute.internal";
private static final String PEER_UNQUALIFIED = "ip-172-20-3-158";
private static final String EXPECTED_IP = "172.20.3.158";

/** Matches the Robot suite's peer-connected window (3 min). */
private static final long CONNECT_TIMEOUT_SEC = 20;
/** Time budget for DNS to start resolving once the engine is connected. */
private static final long RESOLVE_TIMEOUT_SEC = 20;
private VpnTestHarness harness;
private String profileName;

@Before

public void skipIfPreviousFailed() {

FailFast.skipIfAborted();

}


@After
public void tearDown() throws Exception {
if (profileName != null && harness != null) {
harness.disableTouchVisualization();
LoginFlow.removeProfile(E2eAppRule.activity(), harness.device(), profileName);
}
}

@Test
public void resolvesPeerNameThroughTunnel() 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());

MainActivity activity = E2eAppRule.activity();
assertNotNull("MainActivity must be available", activity);
harness = new VpnTestHarness(activity);
harness.enableTouchVisualization();

harness.grantVpnConsent();

profileName = LoginFlow.createAndSwitchToFreshProfile(activity, harness.device(), "dns");
LoginFlow.loginWithSetupKey(activity, harness.device(), setupKey);

boolean connected = harness.connectAndAwait(CONNECT_TIMEOUT_SEC);
if (!connected) {
LoginFlow.dumpScreenshot(harness.device(), "vpn-connect-timeout");
}
assertTrue("VPN did not reach connected state within " + CONNECT_TIMEOUT_SEC + "s",
connected);

// FQDN resolves to the peer's private address through the tunnel DNS.
boolean fqdnResolved = harness.waitForResolve(PEER_FQDN, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
if (!fqdnResolved) {
LoginFlow.dumpScreenshot(harness.device(), "dns-fqdn-unresolved");
}
assertTrue(PEER_FQDN + " did not resolve to " + EXPECTED_IP + " within "
+ RESOLVE_TIMEOUT_SEC + "s", fqdnResolved);

// Search-domain: the unqualified name resolves to the same address.
boolean searchResolved =
harness.waitForResolve(PEER_UNQUALIFIED, EXPECTED_IP, RESOLVE_TIMEOUT_SEC);
if (!searchResolved) {
LoginFlow.dumpScreenshot(harness.device(), "dns-search-unresolved");
}
assertTrue(PEER_UNQUALIFIED + " (search domain) did not resolve to " + EXPECTED_IP
+ " within " + RESOLVE_TIMEOUT_SEC + "s", searchResolved);

Log.i(TAG, "DNS resolution verified: " + PEER_FQDN + " and " + PEER_UNQUALIFIED
+ " -> " + EXPECTED_IP);
}
}
75 changes: 75 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/E2eAppRule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import android.app.Activity;
import android.app.Instrumentation;
import android.content.Intent;

import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;

import java.util.Collection;

/**
* Provides the single shared {@link MainActivity} for the e2e suite WITHOUT a
* JUnit rule: {@link #activity()} returns the currently-resumed MainActivity,
* launching one if none is up. This survives the test framework finishing
* activities between test classes (which made a suite-level ActivityTestRule /
* ActivityScenario hand back a destroyed activity). Because the launch reuses
* the existing task, the app is not torn down and recreated between cases.
*/
final class E2eAppRule {

private static final long LAUNCH_TIMEOUT_MS = 10_000;

private E2eAppRule() {
}

/** The running MainActivity, launched on demand if not already resumed. */
static MainActivity activity() {
MainActivity existing = resumedMainActivity();
if (existing != null) {
return existing;
}

Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
Intent intent = new Intent(instrumentation.getTargetContext(), MainActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
instrumentation.startActivitySync(intent);
instrumentation.waitForIdleSync();

long deadline = System.currentTimeMillis() + LAUNCH_TIMEOUT_MS;
MainActivity activity = resumedMainActivity();
while (activity == null && System.currentTimeMillis() < deadline) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
activity = resumedMainActivity();
}
if (activity == null) {
throw new IllegalStateException("MainActivity did not reach RESUMED within "
+ (LAUNCH_TIMEOUT_MS / 1000) + "s");
}
return activity;
}

private static MainActivity resumedMainActivity() {
MainActivity[] found = new MainActivity[1];
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
Collection<Activity> resumed = ActivityLifecycleMonitorRegistry.getInstance()
.getActivitiesInStage(Stage.RESUMED);
for (Activity a : resumed) {
if (a instanceof MainActivity) {
found[0] = (MainActivity) a;
break;
}
}
});
return found[0];
}
}
42 changes: 42 additions & 0 deletions app/src/androidTest/java/io/netbird/client/e2e/E2eSuite.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.netbird.client.e2e;

import io.netbird.client.MainActivity;

import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.UiDevice;

import org.junit.BeforeClass;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;

/**
* Runs the on-device client e2e tests (the Android port of the Robot
* {@code client-tests.robot} suite). Tests obtain the shared {@link MainActivity}
* via {@link E2eAppRule#activity()}, which reuses the running activity (launching
* one only if none is up), so the app is not restarted between cases.
*
* <p>Run with:
* <pre>
* -Pandroid.testInstrumentationRunnerArguments.class=io.netbird.client.e2e.E2eSuite
* </pre>
*/
@RunWith(Suite.class)
@Suite.SuiteClasses({
SetupKeyAuthTest.class,
PeerConnectivityTest.class,
PortAclTest.class,
DnsResolutionTest.class,
ExitNodeRouteTest.class,
})
public class E2eSuite {

/**
* Force relay is a global setting that defaults ON and would stop the
* relay-less peer connecting. Turn it off ONCE, before any test runs.
*/
@BeforeClass
public static void disableForceRelay() throws Exception {
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
LoginFlow.setForceRelay(E2eAppRule.activity(), device, false);
}
}
Loading
Loading