From 482b350ce0bad239b9b5a25c8dd0506e137c5a70 Mon Sep 17 00:00:00 2001 From: Percy Wegmann Date: Tue, 9 Apr 2024 13:39:07 -0500 Subject: [PATCH] android: add smoke test The test verifies that one can log in via the UI and hit hello.ts.net. Updates tailscale/corp#18202 Signed-off-by: Percy Wegmann --- .gitignore | 1 + Makefile | 18 +- android/build.gradle | 39 ++++- .../com/tailscale/ipn/MainActivityTest.kt | 160 ++++++++++++++++++ .../kotlin/com/tailscale/ipn/TestUtil.kt | 92 ++++++++++ 5 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt create mode 100644 android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt diff --git a/.gitignore b/.gitignore index 95d8806..ff9fe57 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ tailscale-release.aab tailscale-fdroid.apk tailscale-new-fdroid.apk tailscale-new-debug.apk +tailscale-test.apk # Signing key tailscale.jks diff --git a/Makefile b/Makefile index abef385..aa16c83 100644 --- a/Makefile +++ b/Makefile @@ -171,12 +171,26 @@ $(LIBTAILSCALE): Makefile android/libs $(LIBTAILSCALE_SOURCES) $(GOBIN)/gomobile libtailscale: $(LIBTAILSCALE) -tailscale-new-debug.apk: $(LIBTAILSCALE) +ANDROID_SOURCES=$(shell find android -type f -not -path "android/build/*" -not -path '*/.*') +DEBUG_INTERMEDIARY = android/build/outputs/apk/debug/android-debug.apk + +$(DEBUG_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE) + cd android && ./gradlew test assembleDebug + +tailscale-new-debug.apk: $(DEBUG_INTERMEDIARY) (cd android && ./gradlew test assembleDebug) - mv android/build/outputs/apk/debug/android-debug.apk $@ + mv $(DEBUG_INTERMEDIARY) $@ tailscale-new-debug: tailscale-new-debug.apk ## Build the new debug APK +ANDROID_TEST_INTERMEDIARY=./android/build/outputs/apk/androidTest/applicationTest/android-applicationTest-androidTest.apk + +$(ANDROID_TEST_INTERMEDIARY): $(ANDROID_SOURCES) $(LIBTAILSCALE) + cd android && ./gradlew assembleApplicationTestAndroidTest + +tailscale-test.apk: $(ANDROID_TEST_INTERMEDIARY) + mv $(ANDROID_TEST_INTERMEDIARY) $@ + test: $(LIBTAILSCALE) ## Run the Android tests (cd android && ./gradlew test) diff --git a/android/build.gradle b/android/build.gradle index e0b12eb..92edbda 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ buildscript { ext.kotlin_version = "1.9.22" - ext.kotlin_compose_version = "1.5.10" + ext.compose_version = "1.5.10" ext.accompanist_version = "0.34.0" repositories { @@ -39,7 +39,9 @@ android { targetSdkVersion 34 versionCode 198 versionName "1.59.53-t0f042b981-g1017015de26" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 @@ -48,10 +50,21 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion = "$kotlin_compose_version" + kotlinCompilerExtensionVersion = "$compose_version" } flavorDimensions "version" namespace 'com.tailscale.ipn' + + buildTypes { + applicationTest { + initWith debug + buildConfigField "String", "GITHUB_USERNAME", "\"" + getLocalProperty("githubUsername")+"\"" + buildConfigField "String", "GITHUB_PASSWORD", "\"" + getLocalProperty("githubPassword")+"\"" + buildConfigField "String", "GITHUB_2FA_SECRET", "\"" + getLocalProperty("github2FASecret")+"\"" + } + } + + testBuildType "applicationTest" } dependencies { @@ -94,4 +107,26 @@ dependencies { // Tailscale dependencies. implementation ':libtailscale@aar' + + // Tests + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation 'androidx.test.uiautomator:uiautomator:2.3.0' + + // Authentication only for tests + androidTestImplementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0' + androidTestImplementation 'commons-codec:commons-codec:1.16.1' } + +def getLocalProperty(key) { + try { + Properties properties = new Properties() + properties.load(project.file('local.properties').newDataInputStream()) + return properties.getProperty(key); + } catch(Throwable ignored) { + return "" + } +} \ No newline at end of file diff --git a/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt b/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt new file mode 100644 index 0000000..0a7fe5c --- /dev/null +++ b/android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt @@ -0,0 +1,160 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.os.Build +import android.util.Log +import android.widget.Button +import android.widget.EditText +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm +import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig +import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator +import org.apache.commons.codec.binary.Base32 +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +@LargeTest +class MainActivityTest { + companion object { + const val TAG = "MainActivityTest" + } + + @get:Rule val activityRule = activityScenarioRule() + + @Before fun setUp() {} + + @After fun tearDown() {} + + /** + * This test starts with a clean install, logs the user in to a tailnet using credentials provided + * through a build config, and then makes sure we can hit https://hello.ts.net. + */ + @Test + fun loginAndVisitHello() { + val githubUsername = BuildConfig.GITHUB_USERNAME + val githubPassword = BuildConfig.GITHUB_PASSWORD + val github2FASecret = Base32().decode(BuildConfig.GITHUB_2FA_SECRET) + val config = + TimeBasedOneTimePasswordConfig( + codeDigits = 6, + hmacAlgorithm = HmacAlgorithm.SHA1, + timeStep = 30, + timeStepUnit = TimeUnit.SECONDS) + val githubTOTP = TimeBasedOneTimePasswordGenerator(github2FASecret, config) + + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + Log.d(TAG, "Wait for VPN permission prompt and accept") + device.find(By.text("Connection request")) + device.find(By.text("OK")).click() + + Log.d(TAG, "Click through Get Started screen") + device.find(By.text("Get Started")) + device.find(By.text("Get Started")).click() + + asNecessary( + timeout = 2.minutes, + { + Log.d(TAG, "Log in") + device.find(By.text("Log in")).click() + }, + { + Log.d(TAG, "Accept Chrome terms and conditions (if necessary)") + device.find(By.text("Welcome to Chrome")) + val dismissIndex = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 1 else 0 + device.find(UiSelector().instance(dismissIndex).className(Button::class.java)).click() + }, + { + Log.d(TAG, "Don't turn on sync") + device.find(By.text("Turn on sync?")) + device.find(By.text("No thanks")).click() + }, + { + Log.d(TAG, "Log in with GitHub") + device.find(By.text("Sign in with GitHub")).click() + }, + { + Log.d(TAG, "Make sure GitHub page has loaded") + device.find(By.text("New to GitHub")) + device.find(By.text("Username or email address")) + device.find(By.text("Sign in")) + }, + { + Log.d(TAG, "Enter credentials") + device + .find(UiSelector().instance(0).className(EditText::class.java)) + .setText(githubUsername) + device + .find(UiSelector().instance(1).className(EditText::class.java)) + .setText(githubPassword) + device.find(By.text("Sign in")).click() + }, + { + Log.d(TAG, "Enter 2FA") + device.find(By.text("Two-factor authentication")) + device + .find(UiSelector().instance(0).className(EditText::class.java)) + .setText(githubTOTP.generate()) + device.find(UiSelector().instance(0).className(Button::class.java)).click() + }, + { + Log.d(TAG, "Accept Tailscale app") + device.find(By.text("Learn more about OAuth")) + // Sleep a little to give button time to activate + Thread.sleep(5.seconds.inWholeMilliseconds) + device.find(UiSelector().instance(1).className(Button::class.java)).click() + }, + { + Log.d(TAG, "Connect device") + device.find(By.text("Connect device")) + device.find(UiSelector().instance(0).className(Button::class.java)).click() + }, + ) + + try { + Log.d(TAG, "Accept Permission (Either Storage or Notifications)") + device.find(By.text("Continue")).click() + device.find(By.text("Allow")).click() + } catch (t: Throwable) { + // we're not always prompted for permissions, that's okay + } + + Log.d(TAG, "Wait for VPN to connect") + device.find(By.text("Connected")) + + val helloResponse = helloTSNet + Assert.assertTrue( + "Response from hello.ts.net should show success", + helloResponse.contains("You're connected over Tailscale!")) + } +} + +private val helloTSNet: String + get() { + return URL("https://hello.ts.net").run { + openConnection().run { + this as HttpURLConnection + connectTimeout = 30000 + readTimeout = 5000 + inputStream.bufferedReader().readText() + } + } + } diff --git a/android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt b/android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt new file mode 100644 index 0000000..dc10c8f --- /dev/null +++ b/android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt @@ -0,0 +1,92 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.util.Log +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val defaultTimeout = 10.seconds + +private val threadLocalTimeout = ThreadLocal() + +/** + * Wait until the specified timeout for the given selector and return the matching UiObject2. + * Timeout defaults to 10 seconds. + * + * @throws Exception if selector is not found within timeout. + */ +fun UiDevice.find( + selector: BySelector, + timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout +): UiObject2 { + wait(Until.findObject(selector), timeout.inWholeMilliseconds)?.let { + return it + } ?: run { throw Exception("not found") } +} + +/** + * Wait until the specified timeout for the given selector and return the matching UiObject. Timeout + * defaults to 10 seconds. + * + * @throws Exception if selector is not found within timeout. + */ +fun UiDevice.find( + selector: UiSelector, + timeout: Duration = threadLocalTimeout.get() ?: defaultTimeout +): UiObject { + val obj = findObject(selector) + if (!obj.waitForExists(timeout.inWholeMilliseconds)) { + throw Exception("not found") + } + return obj +} + +/** + * Execute an ordered collection of steps as necessary. If an earlier step fails but a subsequent + * step succeeds, this skips the earlier step. This is useful for interruptible sequences like + * logging in that may resume in an intermediate state. + */ +fun asNecessary(timeout: Duration, vararg steps: () -> Unit) { + val interval = 250.milliseconds + // Use a short timeout to avoid waiting on actions that can be skipped + threadLocalTimeout.set(interval) + try { + val start = System.currentTimeMillis() + var furthestSuccessful = -1 + while (System.currentTimeMillis() - start < timeout.inWholeMilliseconds) { + for (i in furthestSuccessful + 1 ..< steps.size) { + val step = steps[i] + try { + step() + furthestSuccessful = i + Log.d("TestUtil.asNecessary", "SUCCESS!") + // Going forward, use the normal timeout on the assumption that subsequent steps will + // succeed. + threadLocalTimeout.remove() + } catch (t: Throwable) { + Log.d("TestUtil.asNecessary", t.toString()) + // Going forward, use a short timeout to avoid waiting on actions that can be skipped + threadLocalTimeout.set(interval) + } + } + if (furthestSuccessful == steps.size - 1) { + // All steps have completed successfully + return + } + // Still some steps left to run + Thread.sleep(interval.inWholeMilliseconds) + } + throw Exception("failed to complete within timeout") + } finally { + threadLocalTimeout.remove() + } +}