Skip to content

Commit

Permalink
android: added smoke test
Browse files Browse the repository at this point in the history
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 <percy@tailscale.com>
  • Loading branch information
oxtoacart committed Apr 11, 2024
1 parent a3638f9 commit 23a35bf
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 4 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
35 changes: 33 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -94,4 +107,22 @@ 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) {
Properties properties = new Properties()
properties.load(project.file('local.properties').newDataInputStream())
return properties.getProperty(key);
}
154 changes: 154 additions & 0 deletions android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package com.tailscale.ipn

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<MainActivity>()

@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"))
device.find(UiSelector().instance(0).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(2.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()
}
}
}
89 changes: 89 additions & 0 deletions android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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<Duration>()

/**
* 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()
}
}

0 comments on commit 23a35bf

Please sign in to comment.