Refocus Tailnet flow on Tailscale
This commit is contained in:
parent
3ebb0a8e61
commit
64103abbea
16 changed files with 1856 additions and 342 deletions
|
|
@ -1,15 +1,31 @@
|
|||
import XCTest
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class BurrowTailnetLoginUITests: XCTestCase {
|
||||
private enum TailnetLoginMode: String, Decodable {
|
||||
case tailscale
|
||||
case discovered
|
||||
}
|
||||
|
||||
private struct TestConfig: Decodable {
|
||||
let email: String
|
||||
let username: String
|
||||
let password: String
|
||||
let mode: TailnetLoginMode?
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTailnetLoginThroughAuthentikWebSession() throws {
|
||||
let email = try requiredEnvironment("BURROW_UI_TEST_EMAIL")
|
||||
let username = ProcessInfo.processInfo.environment["BURROW_UI_TEST_USERNAME"] ?? email
|
||||
let password = try requiredEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||
let config = try loadTestConfig()
|
||||
let email = config.email
|
||||
let username = config.username
|
||||
let password = config.password
|
||||
let mode = config.mode ?? .tailscale
|
||||
let browserIdentity = mode == .tailscale ? email : username
|
||||
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
|
@ -18,51 +34,90 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
XCTAssertTrue(tailnetButton.waitForExistence(timeout: 15), "Tailnet add button did not appear")
|
||||
tailnetButton.tap()
|
||||
|
||||
configureTailnetIfNeeded(in: app, mode: mode)
|
||||
|
||||
let discoveryField = app.textFields["tailnet-discovery-email"]
|
||||
XCTAssertTrue(discoveryField.waitForExistence(timeout: 10), "Tailnet discovery email field did not appear")
|
||||
replaceText(in: discoveryField, with: email)
|
||||
|
||||
let findServerButton = app.buttons["tailnet-find-server"]
|
||||
XCTAssertTrue(findServerButton.waitForExistence(timeout: 5), "Find Server button did not appear")
|
||||
findServerButton.tap()
|
||||
|
||||
let discoveryCard = app.otherElements["tailnet-discovery-card"]
|
||||
XCTAssertTrue(discoveryCard.waitForExistence(timeout: 20), "Tailnet discovery result did not appear")
|
||||
|
||||
let authorityField = app.textFields["tailnet-authority"]
|
||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
||||
XCTAssertTrue(
|
||||
waitForFieldValue(authorityField, containing: "ts.burrow.net", timeout: 20),
|
||||
"Tailnet authority was not populated from discovery"
|
||||
)
|
||||
|
||||
let probeButton = app.buttons["tailnet-check-connection"]
|
||||
XCTAssertTrue(probeButton.waitForExistence(timeout: 5), "Check Connection button did not appear")
|
||||
probeButton.tap()
|
||||
|
||||
let probeCard = app.otherElements["tailnet-authority-probe-card"]
|
||||
XCTAssertTrue(probeCard.waitForExistence(timeout: 20), "Tailnet connection probe did not complete")
|
||||
let serverCard = app.descendants(matching: .any)
|
||||
.matching(identifier: "tailnet-server-card")
|
||||
.firstMatch
|
||||
XCTAssertTrue(serverCard.waitForExistence(timeout: 5), "Tailnet server card did not appear")
|
||||
|
||||
let signInButton = app.buttons["tailnet-start-sign-in"]
|
||||
XCTAssertTrue(signInButton.waitForExistence(timeout: 10), "Tailnet sign-in button did not appear")
|
||||
signInButton.tap()
|
||||
|
||||
acceptAuthenticationPromptIfNeeded(in: app)
|
||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 20)
|
||||
|
||||
let webSession = webAuthenticationSession()
|
||||
XCTAssertTrue(webSession.waitForExistence(timeout: 20), "Safari authentication session did not appear")
|
||||
|
||||
signIntoAuthentik(in: webSession, username: username, password: password)
|
||||
signIntoAuthentik(in: webSession, username: browserIdentity, password: password)
|
||||
|
||||
app.activate()
|
||||
XCTAssertTrue(
|
||||
waitForButtonLabel(app.buttons["tailnet-start-sign-in"], equals: "Signed In", timeout: 60),
|
||||
waitForTailnetSignedIn(in: app, timeout: 60),
|
||||
"Tailnet sign-in never reached the running state"
|
||||
)
|
||||
}
|
||||
|
||||
private func acceptAuthenticationPromptIfNeeded(in app: XCUIApplication) {
|
||||
private func configureTailnetIfNeeded(in app: XCUIApplication, mode: TailnetLoginMode) {
|
||||
guard mode == .discovered else { return }
|
||||
|
||||
openTailnetMenu(in: app)
|
||||
tapMenuButton(named: "Edit Custom Server", in: app)
|
||||
|
||||
openTailnetMenu(in: app)
|
||||
tapMenuButton(named: "Show Advanced Settings", in: app)
|
||||
|
||||
let authorityField = app.textFields["tailnet-authority"]
|
||||
XCTAssertTrue(authorityField.waitForExistence(timeout: 10), "Tailnet authority field did not appear")
|
||||
replaceText(in: authorityField, with: "")
|
||||
}
|
||||
|
||||
private func openTailnetMenu(in app: XCUIApplication) {
|
||||
let moreButton = app.buttons["More"]
|
||||
XCTAssertTrue(moreButton.waitForExistence(timeout: 5), "Tailnet menu button did not appear")
|
||||
moreButton.tap()
|
||||
}
|
||||
|
||||
private func tapMenuButton(named title: String, in app: XCUIApplication) {
|
||||
let menuButton = firstExistingElement(
|
||||
from: [
|
||||
app.buttons[title],
|
||||
app.descendants(matching: .button)[title],
|
||||
],
|
||||
timeout: 5
|
||||
)
|
||||
XCTAssertTrue(menuButton.exists, "Menu action \(title) did not appear")
|
||||
menuButton.tap()
|
||||
}
|
||||
|
||||
private func acceptAuthenticationPromptIfNeeded(
|
||||
in app: XCUIApplication,
|
||||
timeout: TimeInterval
|
||||
) {
|
||||
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
repeat {
|
||||
let promptCandidates = [
|
||||
springboard.buttons["Continue"],
|
||||
springboard.buttons["Allow"],
|
||||
app.buttons["Continue"],
|
||||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.exists && button.isHittable {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||
} while Date() < deadline
|
||||
|
||||
let promptCandidates = [
|
||||
springboard.buttons["Continue"],
|
||||
springboard.buttons["Allow"],
|
||||
|
|
@ -70,7 +125,7 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
app.buttons["Allow"],
|
||||
]
|
||||
|
||||
for button in promptCandidates where button.waitForExistence(timeout: 3) {
|
||||
for button in promptCandidates where button.exists {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
|
|
@ -88,6 +143,19 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func signIntoAuthentik(in webSession: XCUIApplication, username: String, password: String) {
|
||||
followTailnetRedirectIfNeeded(in: webSession)
|
||||
|
||||
if !webSession.exists {
|
||||
return
|
||||
}
|
||||
|
||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||
if immediatePasswordField.exists {
|
||||
replaceSecureText(in: immediatePasswordField, within: webSession, with: password)
|
||||
submitAuthenticationForm(in: webSession, focusedField: immediatePasswordField)
|
||||
return
|
||||
}
|
||||
|
||||
let usernameField = firstExistingElement(
|
||||
in: webSession,
|
||||
queries: [
|
||||
|
|
@ -99,21 +167,12 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
{ $0.webViews.textFields["Email or Username"] },
|
||||
{ $0.descendants(matching: .textField).firstMatch },
|
||||
],
|
||||
timeout: 25
|
||||
timeout: 12
|
||||
)
|
||||
XCTAssertTrue(usernameField.exists, "Authentik username field did not appear")
|
||||
replaceText(in: usernameField, with: username)
|
||||
|
||||
let immediatePasswordField = firstExistingSecureField(in: webSession, timeout: 2)
|
||||
if immediatePasswordField.exists {
|
||||
replaceSecureText(in: immediatePasswordField, with: password)
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
if !usernameField.exists {
|
||||
return
|
||||
}
|
||||
replaceText(in: usernameField, with: username)
|
||||
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
|
|
@ -123,21 +182,31 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
|
||||
let passwordField = firstExistingSecureField(in: webSession, timeout: 20)
|
||||
XCTAssertTrue(passwordField.exists, "Authentik password field did not appear")
|
||||
replaceSecureText(in: passwordField, with: password)
|
||||
tapFirstExistingButton(
|
||||
in: webSession,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
replaceSecureText(in: passwordField, within: webSession, with: password)
|
||||
submitAuthenticationForm(in: webSession, focusedField: passwordField)
|
||||
}
|
||||
|
||||
private func followTailnetRedirectIfNeeded(in webSession: XCUIApplication) {
|
||||
let redirectCandidates = [
|
||||
webSession.links["Found"],
|
||||
webSession.webViews.links["Found"],
|
||||
webSession.buttons["Found"],
|
||||
webSession.webViews.buttons["Found"],
|
||||
]
|
||||
|
||||
let redirectLink = firstExistingElement(from: redirectCandidates, timeout: 8)
|
||||
if redirectLink.exists {
|
||||
redirectLink.tap()
|
||||
}
|
||||
}
|
||||
|
||||
private func firstExistingSecureField(in app: XCUIApplication, timeout: TimeInterval) -> XCUIElement {
|
||||
let candidates = [
|
||||
app.descendants(matching: .secureTextField).firstMatch,
|
||||
app.secureTextFields["Password"],
|
||||
app.secureTextFields["Password or Token"],
|
||||
app.webViews.secureTextFields["Password"],
|
||||
app.webViews.secureTextFields["Password or Token"],
|
||||
app.descendants(matching: .secureTextField).firstMatch,
|
||||
]
|
||||
|
||||
return firstExistingElement(from: candidates, timeout: timeout)
|
||||
|
|
@ -160,11 +229,92 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
button.tap()
|
||||
}
|
||||
|
||||
private func requiredEnvironment(_ key: String) throws -> String {
|
||||
guard let value = ProcessInfo.processInfo.environment[key],
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
private func submitAuthenticationForm(in app: XCUIApplication, focusedField: XCUIElement) {
|
||||
focus(focusedField)
|
||||
focusedField.typeText("\n")
|
||||
if waitForAny(
|
||||
[
|
||||
{ !focusedField.exists },
|
||||
{ !app.staticTexts["Burrow Tailnet Authentication"].exists },
|
||||
],
|
||||
timeout: 1.5
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let keyboard = app.keyboards.firstMatch
|
||||
if keyboard.waitForExistence(timeout: 2) {
|
||||
let keyboardCandidates = [
|
||||
"Return",
|
||||
"return",
|
||||
"Go",
|
||||
"go",
|
||||
"Continue",
|
||||
"continue",
|
||||
"Done",
|
||||
"done",
|
||||
"Join",
|
||||
"join",
|
||||
"Sign In",
|
||||
"Log In",
|
||||
"Login",
|
||||
]
|
||||
for title in keyboardCandidates {
|
||||
let key = keyboard.buttons[title]
|
||||
if key.exists && key.isHittable {
|
||||
key.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let lastKey = keyboard.buttons.allElementsBoundByIndex.last,
|
||||
lastKey.exists,
|
||||
lastKey.isHittable
|
||||
{
|
||||
lastKey.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tapFirstExistingButton(
|
||||
in: app,
|
||||
titles: ["Continue", "Sign In", "Log in", "Login"],
|
||||
timeout: 5
|
||||
)
|
||||
}
|
||||
|
||||
private func loadTestConfig() throws -> TestConfig {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
if let email = nonEmptyEnvironment("BURROW_UI_TEST_EMAIL"),
|
||||
let password = nonEmptyEnvironment("BURROW_UI_TEST_PASSWORD")
|
||||
{
|
||||
return TestConfig(
|
||||
email: email,
|
||||
username: nonEmptyEnvironment("BURROW_UI_TEST_USERNAME") ?? email,
|
||||
password: password,
|
||||
mode: nonEmptyEnvironment("BURROW_UI_TEST_TAILNET_MODE")
|
||||
.flatMap(TailnetLoginMode.init(rawValue:))
|
||||
)
|
||||
}
|
||||
|
||||
let configPath = environment["BURROW_UI_TEST_CONFIG_PATH"] ?? "/tmp/burrow-ui-test-config.json"
|
||||
let configURL = URL(fileURLWithPath: configPath)
|
||||
guard FileManager.default.fileExists(atPath: configURL.path) else {
|
||||
throw XCTSkip(
|
||||
"Missing UI test configuration. Expected env vars or config file at \(configURL.path)"
|
||||
)
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: configURL)
|
||||
return try JSONDecoder().decode(TestConfig.self, from: data)
|
||||
}
|
||||
|
||||
private func nonEmptyEnvironment(_ key: String) -> String? {
|
||||
guard let value = ProcessInfo.processInfo.environment[key]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty
|
||||
else {
|
||||
throw XCTSkip("Missing required UI test environment variable \(key)")
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
|
@ -189,6 +339,32 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func waitForTailnetSignedIn(in app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let button = app.buttons["tailnet-start-sign-in"]
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
repeat {
|
||||
acceptAuthenticationPromptIfNeeded(in: app, timeout: 1)
|
||||
if button.exists, button.label == "Signed In" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
} while Date() < deadline
|
||||
|
||||
return button.exists && button.label == "Signed In"
|
||||
}
|
||||
|
||||
private func waitForAny(_ conditions: [() -> Bool], timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
repeat {
|
||||
if conditions.contains(where: { $0() }) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
} while Date() < deadline
|
||||
return conditions.contains(where: { $0() })
|
||||
}
|
||||
|
||||
private func firstExistingElement(
|
||||
in app: XCUIApplication,
|
||||
queries: [(XCUIApplication) -> XCUIElement],
|
||||
|
|
@ -210,14 +386,27 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func replaceText(in element: XCUIElement, with value: String) {
|
||||
element.tap()
|
||||
focus(element)
|
||||
clearText(in: element)
|
||||
element.typeText(value)
|
||||
}
|
||||
|
||||
private func replaceSecureText(in element: XCUIElement, with value: String) {
|
||||
element.tap()
|
||||
clearText(in: element)
|
||||
private func replaceSecureText(in element: XCUIElement, within app: XCUIApplication, with value: String) {
|
||||
UIPasteboard.general.string = value
|
||||
focus(element)
|
||||
for revealMenu in [
|
||||
{ element.doubleTap() },
|
||||
{ element.press(forDuration: 1.2) },
|
||||
] {
|
||||
revealMenu()
|
||||
let pasteButton = firstExistingElement(from: pasteCandidates(in: app), timeout: 3)
|
||||
if pasteButton.exists {
|
||||
pasteButton.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
focus(element)
|
||||
element.typeText(value)
|
||||
}
|
||||
|
||||
|
|
@ -229,4 +418,22 @@ final class BurrowTailnetLoginUITests: XCTestCase {
|
|||
let deleteSequence = String(repeating: XCUIKeyboardKey.delete.rawValue, count: currentValue.count)
|
||||
element.typeText(deleteSequence)
|
||||
}
|
||||
|
||||
private func focus(_ element: XCUIElement) {
|
||||
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||
}
|
||||
|
||||
private func pasteCandidates(in app: XCUIApplication) -> [XCUIElement] {
|
||||
let pasteLabels = ["Paste", "Incolla", "Paste from Clipboard"]
|
||||
return pasteLabels.flatMap { label in
|
||||
[
|
||||
app.menuItems[label],
|
||||
app.buttons[label],
|
||||
app.webViews.buttons[label],
|
||||
app.descendants(matching: .button).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||
app.descendants(matching: .menuItem).matching(NSPredicate(format: "label == %@", label)).firstMatch,
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue