-
Notifications
You must be signed in to change notification settings - Fork 493
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
5 changed files
with
293 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
154 changes: 154 additions & 0 deletions
154
android/src/androidTest/kotlin/com/tailscale/ipn/MainActivityTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
89
android/src/androidTest/kotlin/com/tailscale/ipn/TestUtil.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |