Files
openclaw/apps/ios/UITests/OpenClawSnapshotUITests.swift
Josh Avant 6438c89f05 fix(mobile): clarify gateway connection security setup (#101325)
* fix(mobile): clarify gateway connection setup

* chore(i18n): sync native mobile strings

* fix(mobile): align manual gateway previews

* chore(i18n): refresh native source inventory

* chore(android): remove unused connect tab strings

* test(i18n): drop removed connect tab sentinel

* fix(ios): brand connection security picker labels

* fix(mobile): hide gateway endpoint previews

* chore(i18n): sync native source inventory

* fix(mobile): remove redundant TLS helper copy
2026-07-07 14:43:11 -05:00

953 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import XCTest
@MainActor
final class OpenClawSnapshotUITests: XCTestCase {
private struct ScreenshotTarget {
let initialTab: String
let initialDestination: String
let name: String
}
private static let screenshotTargets = [
ScreenshotTarget(initialTab: "control", initialDestination: "overview", name: "01-control-connected"),
ScreenshotTarget(initialTab: "chat", initialDestination: "chat", name: "02-chat-connected"),
ScreenshotTarget(initialTab: "talk", initialDestination: "talk", name: "03-talk-connected"),
ScreenshotTarget(initialTab: "agent", initialDestination: "agents", name: "04-agent-connected"),
ScreenshotTarget(initialTab: "settings", initialDestination: "settings", name: "05-settings-connected"),
]
private var app: XCUIApplication?
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
}
override func tearDownWithError() throws {
self.app?.terminate()
self.app = nil
try super.tearDownWithError()
}
func testConnectedGatewayTabs() {
for target in Self.screenshotTargets {
self.launchApp(for: target)
snapshot(target.name, timeWaitingForIdle: 5)
self.attachScreenshot(named: target.name)
}
}
func testOnboardingExplainsCapabilitiesAndTrust() {
let app = XCUIApplication()
app.launchArguments += ["--openclaw-reset-onboarding"]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Security notice"].exists)
let disclosure = app.staticTexts.matching(NSPredicate(
format: "label CONTAINS[c] 'camera' AND label CONTAINS[c] 'trust the gateway and agent'")).firstMatch
XCTAssertTrue(disclosure.exists)
self.attachScreenshot(named: "onboarding-capabilities-and-trust")
}
func testControlOverviewNavigation() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone control hub only")
self.launchApp(for: ScreenshotTarget(
initialTab: "control",
initialDestination: "control",
name: "control-overview-navigation"))
let overview = self.app?.buttons.containing(.staticText, identifier: "Overview").firstMatch
XCTAssertTrue(overview?.waitForExistence(timeout: 5) == true)
overview?.tap()
XCTAssertTrue(self.app?.navigationBars.buttons["Control"].waitForExistence(timeout: 5) == true)
XCTAssertTrue(self.app?.buttons["Gateway settings"].waitForExistence(timeout: 5) == true)
XCTAssertEqual(self.app?.state, .runningForeground)
}
func testLocationAlwaysWaitsForSlowSystemPermissionResponse() throws {
XCUIApplication().resetAuthorizationStatus(for: .location)
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "location-always-slow-prompt"))
let permissions = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
XCTAssertTrue(permissions.waitForExistence(timeout: 8))
permissions.tap()
let offMode = try XCTUnwrap(self.app?.buttons["Off"])
if !offMode.isSelected {
offMode.tap()
XCTAssertTrue(offMode.isSelected)
}
let alwaysMode = try XCTUnwrap(self.app?.buttons["Always"])
XCTAssertTrue(alwaysMode.waitForExistence(timeout: 5))
alwaysMode.tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let prompt = springboard.alerts.firstMatch
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
XCTAssertTrue(alwaysMode.isSelected)
XCTAssertTrue(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
self.attachFullScreenScreenshot(named: "location-always-first-prompt-after-3s")
let firstAllow = prompt.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'While Using'")).firstMatch
XCTAssertTrue(firstAllow.exists)
firstAllow.tap()
if prompt.waitForExistence(timeout: 5) {
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
XCTAssertTrue(alwaysMode.isSelected)
XCTAssertTrue(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
self.attachFullScreenScreenshot(named: "location-always-upgrade-prompt-after-3s")
let changeToAlways = prompt.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'Change to Always'")).firstMatch
XCTAssertTrue(changeToAlways.exists)
changeToAlways.tap()
}
self.app?.activate()
XCTAssertTrue(alwaysMode.waitForExistence(timeout: 5))
XCTAssertTrue(alwaysMode.isSelected)
XCTAssertFalse(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
let backgroundAllowed = try XCTUnwrap(self.app?.staticTexts.matching(
NSPredicate(format: "label BEGINSWITH %@", "Background location requests")).firstMatch)
XCTAssertTrue(backgroundAllowed.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 1)
self.attachScreenshot(named: "location-always-granted-after-slow-prompt")
}
func testLocationWhileUsingStaysSelectedAfterSlowSystemPermissionResponse() throws {
XCUIApplication().resetAuthorizationStatus(for: .location)
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "location-while-using-slow-prompt"))
let permissions = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
XCTAssertTrue(permissions.waitForExistence(timeout: 8))
permissions.tap()
let offMode = try XCTUnwrap(self.app?.buttons["Off"])
if !offMode.isSelected {
offMode.tap()
XCTAssertTrue(offMode.isSelected)
}
let whileUsingMode = try XCTUnwrap(self.app?.buttons["While Using"])
XCTAssertTrue(whileUsingMode.waitForExistence(timeout: 5))
whileUsingMode.tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let prompt = springboard.alerts.firstMatch
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
XCTAssertTrue(whileUsingMode.isSelected)
XCTAssertTrue(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
let allow = prompt.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'While Using'")).firstMatch
XCTAssertTrue(allow.exists)
allow.tap()
self.app?.activate()
XCTAssertTrue(whileUsingMode.waitForExistence(timeout: 5))
XCTAssertTrue(whileUsingMode.isSelected)
XCTAssertFalse(self.app?.staticTexts["Requesting iOS location permission…"].exists == true)
let foregroundAllowed = try XCTUnwrap(self.app?.staticTexts.matching(
NSPredicate(format: "label BEGINSWITH %@", "Foreground location requests")).firstMatch)
XCTAssertTrue(foregroundAllowed.waitForExistence(timeout: 5))
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "location-while-using-relaunch"))
let relaunchedPermissions = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
XCTAssertTrue(relaunchedPermissions.waitForExistence(timeout: 8))
relaunchedPermissions.tap()
XCTAssertTrue(self.app?.buttons["While Using"].isSelected == true)
}
func testSettingsBackReturnsToOriginatingPhoneTab() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone settings navigation only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-settings-back"))
let gatewaySettings = try XCTUnwrap(self.app?.buttons["chat-gateway-status"])
XCTAssertTrue(gatewaySettings.waitForExistence(timeout: 8))
gatewaySettings.tap()
let gatewayNavigationBar = try XCTUnwrap(self.app?.navigationBars["Gateway"])
XCTAssertTrue(gatewayNavigationBar.waitForExistence(timeout: 5))
XCTAssertTrue(self.app?.tabBars.buttons["Chat"].isSelected == true)
self.attachScreenshot(named: "chat-gateway-origin-stack")
gatewayNavigationBar.buttons["BackButton"].tap()
XCTAssertTrue(gatewaySettings.waitForExistence(timeout: 5))
XCTAssertTrue(self.app?.tabBars.buttons["Chat"].isSelected == true)
self.attachScreenshot(named: "chat-after-settings-back")
self.launchApp(for: ScreenshotTarget(
initialTab: "talk",
initialDestination: "talk",
name: "talk-settings-back"))
let voiceSettings = try XCTUnwrap(self.app?.buttons["talk-voice-settings-control"])
for _ in 0..<3 where !voiceSettings.exists {
self.app?.swipeUp()
}
XCTAssertTrue(voiceSettings.waitForExistence(timeout: 8))
voiceSettings.tap()
let voiceNavigationBar = try XCTUnwrap(self.app?.navigationBars["Voice & Talk"])
XCTAssertTrue(voiceNavigationBar.waitForExistence(timeout: 5))
XCTAssertTrue(self.app?.tabBars.buttons["Talk"].isSelected == true)
voiceNavigationBar.buttons["BackButton"].tap()
XCTAssertTrue(voiceSettings.waitForExistence(timeout: 5))
XCTAssertTrue(self.app?.tabBars.buttons["Talk"].isSelected == true)
}
func testVoiceWakeResumesAfterTalkModeToggle() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Settings proof only")
self.addUIInterruptionMonitor(withDescription: "Microphone and speech permissions") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "voice-wake-talk-lifecycle"))
let voiceSettings = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Voice & Talk").firstMatch)
XCTAssertTrue(voiceSettings.waitForExistence(timeout: 8))
voiceSettings.tap()
let voiceWake = try XCTUnwrap(self.app?.switches["Voice Wake"])
let talkMode = try XCTUnwrap(self.app?.switches["Talk Mode"])
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
XCTAssertTrue(talkMode.exists)
if talkMode.value as? String == "1" {
talkMode.tap()
}
if voiceWake.value as? String == "1" {
voiceWake.tap()
}
voiceWake.tap()
XCTAssertEqual(voiceWake.value as? String, "1")
talkMode.tap()
XCTAssertEqual(talkMode.value as? String, "1")
talkMode.tap()
XCTAssertEqual(talkMode.value as? String, "0")
XCTAssertEqual(voiceWake.value as? String, "1")
XCTAssertEqual(self.app?.state, .runningForeground)
self.attachScreenshot(named: "voice-wake-after-talk-resume")
let voiceNavigationBar = try XCTUnwrap(self.app?.navigationBars["Voice & Talk"])
voiceNavigationBar.buttons["BackButton"].tap()
let diagnostics = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Diagnostics").firstMatch)
XCTAssertTrue(diagnostics.waitForExistence(timeout: 5))
diagnostics.tap()
let voiceWakeStatus = try XCTUnwrap(
self.app?.descendants(matching: .any)["diagnostics-voice-wake-status"])
XCTAssertTrue(voiceWakeStatus.waitForExistence(timeout: 5))
let resumed = expectation(
for: NSPredicate(
format: "value == %@",
"Voice Wake isnt supported on Simulator"),
evaluatedWith: voiceWakeStatus)
wait(for: [resumed], timeout: 5)
let diagnosticsNavigationBar = try XCTUnwrap(self.app?.navigationBars["Diagnostics"])
diagnosticsNavigationBar.buttons["BackButton"].tap()
voiceSettings.tap()
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
voiceWake.tap()
XCTAssertEqual(voiceWake.value as? String, "0")
}
func testChatComposerStartsCompactAndGrowsWithDraft() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone composer proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-composer-growth"))
let textField = try XCTUnwrap(app?.textFields["chat-message-input"])
XCTAssertTrue(textField.waitForExistence(timeout: 8))
let talkButton = try XCTUnwrap(app?.buttons["chat-realtime-control"])
XCTAssertTrue(talkButton.waitForExistence(timeout: 5))
let attachmentButton = try XCTUnwrap(app?.buttons["chat-attachment-picker"])
XCTAssertTrue(attachmentButton.waitForExistence(timeout: 5))
let composerSurface = try XCTUnwrap(app?.otherElements["chat-composer-surface"])
XCTAssertTrue(composerSurface.waitForExistence(timeout: 5))
let gatewayStatus = try XCTUnwrap(app?.buttons["chat-gateway-status"])
XCTAssertTrue(gatewayStatus.waitForExistence(timeout: 5))
let sendButton = try XCTUnwrap(app?.buttons["chat-send-message"])
XCTAssertFalse(sendButton.exists)
XCTAssertLessThanOrEqual(attachmentButton.frame.maxX, composerSurface.frame.minX)
XCTAssertLessThanOrEqual(composerSurface.frame.maxX, talkButton.frame.minX)
XCTAssertGreaterThanOrEqual(attachmentButton.frame.width, 44)
XCTAssertGreaterThanOrEqual(attachmentButton.frame.height, 44)
XCTAssertGreaterThanOrEqual(talkButton.frame.width, 44)
XCTAssertGreaterThanOrEqual(talkButton.frame.height, 44)
let compactHeight = textField.frame.height
XCTAssertLessThanOrEqual(compactHeight, 44)
XCTAssertLessThanOrEqual(abs(talkButton.frame.midY - textField.frame.midY), 1)
self.attachScreenshot(named: "chat-composer-compact")
textField.tap()
textField.typeText(
"Draft a polished launch note that covers the new design, validation, rollout plan, " +
"and follow-up details for the team.")
let composerGrew = expectation(
for: NSPredicate { _, _ in textField.frame.height >= compactHeight + 12 },
evaluatedWith: textField)
wait(for: [composerGrew], timeout: 4)
XCTAssertTrue(sendButton.waitForExistence(timeout: 3))
XCTAssertTrue(talkButton.waitForNonExistence(timeout: 3))
XCTAssertGreaterThanOrEqual(sendButton.frame.width, 44)
XCTAssertGreaterThanOrEqual(sendButton.frame.height, 44)
self.attachScreenshot(named: "chat-composer-expanded")
self.app?.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
XCTAssertTrue(self.app?.keyboards.firstMatch.waitForNonExistence(timeout: 3) == true)
}
func testChatPresentationInLightAppearance() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone chat proof only")
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-light"),
appearance: "light")
XCTAssertTrue(self.app?.buttons["chat-gateway-status"].waitForExistence(timeout: 8) == true)
XCTAssertTrue(self.app?.otherElements["chat-composer-surface"].exists == true)
self.attachScreenshot(named: "chat-light")
}
func testEmptyChatStarterPromptSendsMessage() throws {
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-empty-starters"),
additionalArguments: ["--openclaw-empty-chat-fixture"])
let starter = try XCTUnwrap(self.app?.buttons["chat-starter-summarize-status"])
XCTAssertTrue(starter.waitForExistence(timeout: 8))
XCTAssertTrue(self.app?.staticTexts["What would you like to work on?"].exists == true)
self.attachScreenshot(named: "chat-empty-starters")
starter.tap()
let sentText = "Summarize the current OpenClaw status and tell me what needs attention."
let sentRows = self.app?.staticTexts.matching(NSPredicate(format: "label == %@", sentText))
XCTAssertTrue(sentRows?.firstMatch.waitForExistence(timeout: 5) == true)
XCTAssertEqual(sentRows?.count, 1)
XCTAssertTrue(
self.app?.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "I can help with"))
.firstMatch.waitForExistence(timeout: 5) == true)
self.attachScreenshot(named: "chat-starter-response")
}
func testEmptyChatStarterPromptsLocalizeInGerman() throws {
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-empty-starters-german"),
additionalArguments: [
"--openclaw-empty-chat-fixture",
"-AppleLanguages",
"(de)",
"-AppleLocale",
"de_DE",
])
XCTAssertTrue(self.app?.staticTexts["Woran möchtest du arbeiten?"].waitForExistence(timeout: 8) == true)
let starter = try XCTUnwrap(self.app?.buttons["OpenClaw-Status prüfen"])
XCTAssertTrue(starter.exists)
starter.tap()
XCTAssertTrue(
self.app?.staticTexts[
"Fasse den aktuellen OpenClaw-Status zusammen und sage mir, was Aufmerksamkeit erfordert.",
].waitForExistence(timeout: 5) == true)
self.attachScreenshot(named: "chat-empty-starters-german")
}
func testOnboardingPairCommandAndCompletionOpenChat() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone onboarding proof only")
self.addUIInterruptionMonitor(withDescription: "Local network access") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
let app = XCUIApplication()
setupSnapshot(app)
app.launchArguments += [
"--openclaw-reset-onboarding",
"--openclaw-initial-tab",
"settings",
"--openclaw-initial-destination",
"settings",
]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
let copySetupCommand = app.buttons["Copy setup code command"]
XCTAssertTrue(copySetupCommand.waitForExistence(timeout: 8))
copySetupCommand.tap()
XCTAssertEqual(copySetupCommand.value as? String, "Copied")
self.attachScreenshot(named: "onboarding-copy-setup-code-command")
app.buttons["Connect Manually"].tap()
let setupCode = app.textFields["Enter setup code"]
XCTAssertTrue(setupCode.waitForExistence(timeout: 5))
setupCode.tap()
setupCode.typeText("APPLE-REVIEW-DEMO")
app.buttons["Dismiss Keyboard"].tap()
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Apple Review Demo Gateway"].exists)
XCTAssertTrue(app.staticTexts["Local demo mode"].exists)
self.attachScreenshot(named: "onboarding-connected-go-to-chat")
app.buttons["Go to Chat"].tap()
XCTAssertTrue(app.tabBars.buttons["Chat"].waitForExistence(timeout: 5))
XCTAssertTrue(app.tabBars.buttons["Chat"].isSelected)
}
func testTalkUsesNativeControls() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Talk controls only")
self.launchApp(for: ScreenshotTarget(
initialTab: "talk",
initialDestination: "talk",
name: "talk-native-controls"))
let speakerphone = try XCTUnwrap(app?.switches["talk-speakerphone-control"])
let backgroundListening = try XCTUnwrap(app?.switches["talk-background-listening-control"])
let voiceSettings = try XCTUnwrap(app?.buttons["talk-voice-settings-control"])
for _ in 0..<3 where !speakerphone.exists {
self.app?.swipeUp()
}
XCTAssertTrue(speakerphone.waitForExistence(timeout: 8))
XCTAssertTrue(backgroundListening.exists)
XCTAssertTrue(voiceSettings.exists)
let originalValue = speakerphone.value as? String
defer {
if speakerphone.value as? String != originalValue {
speakerphone.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
}
}
if originalValue == "0" {
speakerphone.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
self.waitForValue("1", of: speakerphone)
}
XCTAssertEqual(speakerphone.value as? String, "1")
self.attachScreenshot(named: "talk-native-controls")
speakerphone.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
self.waitForValue("0", of: speakerphone)
}
func testAppearanceUsesSettingsRow() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "appearance-compact"), appearance: nil, screenshotMode: false)
if self.app?.buttons["Close"].waitForExistence(timeout: 2) == true {
self.app?.buttons["Close"].tap()
}
let row = try XCTUnwrap(self.app?.buttons["settings-appearance-row"])
XCTAssertTrue(row.waitForExistence(timeout: 8))
XCTAssertFalse(self.app?.buttons["settings-appearance-menu"].exists == true)
XCTAssertFalse(self.app?.segmentedControls["settings-appearance-picker"].exists == true)
row.tap()
let navigationBar = try XCTUnwrap(self.app?.navigationBars["Appearance"])
XCTAssertTrue(navigationBar.waitForExistence(timeout: 3))
let system = try XCTUnwrap(self.app?.buttons["settings-appearance-system"])
let light = try XCTUnwrap(self.app?.buttons["settings-appearance-light"])
let dark = try XCTUnwrap(self.app?.buttons["settings-appearance-dark"])
XCTAssertTrue(system.exists)
XCTAssertTrue(light.exists)
XCTAssertTrue(dark.exists)
if system.value as? String != "Selected" {
system.tap()
XCTAssertTrue(row.waitForExistence(timeout: 3))
self.waitForValue("System", of: row)
row.tap()
XCTAssertTrue(navigationBar.waitForExistence(timeout: 3))
self.waitForValue("Selected", of: system)
}
Thread.sleep(forTimeInterval: 1)
self.attachScreenshot(named: "appearance-system")
dark.tap()
XCTAssertTrue(row.waitForExistence(timeout: 3))
self.waitForValue("Dark", of: row)
self.assertDarkAppearanceTextVisible()
self.attachScreenshot(named: "settings-dark")
row.tap()
XCTAssertTrue(navigationBar.waitForExistence(timeout: 3))
system.tap()
XCTAssertTrue(row.waitForExistence(timeout: 3))
self.waitForValue("System", of: row)
Thread.sleep(forTimeInterval: 1)
self.attachScreenshot(named: "appearance-system-restored")
}
func testChatReturnsToOriginatingControlDetail() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Control proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "control",
initialDestination: "activity",
name: "control-chat-return"))
let chatTab = try XCTUnwrap(self.app?.tabBars.buttons["Chat"])
let controlTab = try XCTUnwrap(self.app?.tabBars.buttons["Control"])
// Retain an embedded Chat Settings route, then prove contextual routing pops it.
chatTab.tap()
let gatewaySettings = try XCTUnwrap(self.app?.buttons["chat-gateway-status"])
XCTAssertTrue(gatewaySettings.waitForExistence(timeout: 5))
gatewaySettings.tap()
XCTAssertTrue(self.app?.navigationBars["Gateway"].waitForExistence(timeout: 5) == true)
controlTab.tap()
XCTAssertTrue(self.app?.navigationBars["Control"].waitForExistence(timeout: 8) == true)
let activity = try XCTUnwrap(self.app?.buttons["Activity"])
XCTAssertTrue(activity.waitForExistence(timeout: 5))
activity.tap()
let recentActivity = try XCTUnwrap(self.app?.staticTexts["Recent activity"])
XCTAssertTrue(recentActivity.waitForExistence(timeout: 8))
self.attachScreenshot(named: "control-activity-before-chat")
let activityChat = try self.controlDetailChatButton(above: chatTab)
activityChat.tap()
XCTAssertTrue(chatTab.isSelected)
let returnButton = try XCTUnwrap(self.app?.buttons["OpenClawChatBackToControlDetailButton"])
XCTAssertTrue(returnButton.waitForExistence(timeout: 5))
XCTAssertEqual(returnButton.label, "Back to Activity")
self.attachScreenshot(named: "chat-return-to-activity")
returnButton.tap()
XCTAssertTrue(recentActivity.waitForExistence(timeout: 8))
XCTAssertTrue(controlTab.isSelected)
self.attachScreenshot(named: "control-activity-after-chat")
try self.controlDetailChatButton(above: chatTab).tap()
XCTAssertTrue(chatTab.isSelected)
controlTab.tap()
XCTAssertTrue(self.app?.navigationBars["Control"].waitForExistence(timeout: 8) == true)
let overview = try XCTUnwrap(self.app?.buttons["Overview"])
XCTAssertTrue(overview.exists)
self.attachScreenshot(named: "control-tab-returns-to-root")
overview.tap()
XCTAssertTrue(self.app?.staticTexts["Agent session"].waitForExistence(timeout: 8) == true)
let agentSession = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Molty").firstMatch)
XCTAssertTrue(agentSession.waitForExistence(timeout: 8))
agentSession.tap()
XCTAssertTrue(returnButton.waitForExistence(timeout: 8))
XCTAssertEqual(returnButton.label, "Back to Overview")
self.attachScreenshot(named: "chat-session-return-to-overview")
returnButton.tap()
XCTAssertTrue(self.app?.navigationBars["Overview"].waitForExistence(timeout: 8) == true)
}
func testAgentUsesToolbarFilter() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Agent proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "agent",
initialDestination: "agents",
name: "agent-toolbar-filter"))
let menu = try XCTUnwrap(app?.buttons["agent-status-filter-menu"])
XCTAssertTrue(menu.waitForExistence(timeout: 8))
XCTAssertFalse(self.app?.segmentedControls["Agent status"].exists == true)
menu.tap()
XCTAssertTrue(self.app?.buttons["All"].waitForExistence(timeout: 3) == true)
XCTAssertTrue(self.app?.buttons["Online"].exists == true)
XCTAssertTrue(self.app?.buttons["Ready"].exists == true)
self.attachScreenshot(named: "agent-toolbar-filter")
}
func testLiveGatewayChatRoundTripAndControlOverview() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone chat proof only")
let app = try launchPairedLiveGatewayApp(initialTab: "chat", initialDestination: "chat")
// Build scrollable history through the paired app before checking reader behavior.
for index in 0..<3 {
let seedMarker = "OPENCLAW_E2E_SEED_\(index)_\(Int(Date().timeIntervalSince1970 * 1000))"
let seedContext = String(repeating: "Reader context \(index). ", count: 6)
self.sendLiveGatewayMessage(
"\(seedContext)Reply exactly with \(seedMarker) and no other text.",
expecting: seedMarker,
in: app)
}
let replyMarker = "OPENCLAW_E2E_OK_\(Int(Date().timeIntervalSince1970 * 1000))"
self.sendLiveGatewayMessage(
"Reply exactly with \(replyMarker) and no other text.",
expecting: replyMarker,
in: app)
let jumpToLatest = app.buttons["Jump to latest reply"]
XCTAssertTrue(jumpToLatest.waitForExistence(timeout: 3))
self.attachScreenshot(named: "live-gateway-chat-reply-anchored")
jumpToLatest.tap()
XCTAssertTrue(jumpToLatest.waitForNonExistence(timeout: 3))
XCTAssertTrue(app.staticTexts[replyMarker].exists)
Thread.sleep(forTimeInterval: 0.5)
self.attachScreenshot(named: "live-gateway-chat-jumped-to-latest")
let transcript = app.scrollViews.firstMatch
XCTAssertTrue(transcript.exists)
transcript.swipeDown()
XCTAssertTrue(jumpToLatest.waitForExistence(timeout: 3))
self.attachScreenshot(named: "live-gateway-chat-manual-departure")
jumpToLatest.tap()
XCTAssertTrue(jumpToLatest.waitForNonExistence(timeout: 3))
let controlApp = self.relaunchConnectedLiveGatewayApp(
initialTab: "control",
initialDestination: "control")
let overview = controlApp.buttons.containing(.staticText, identifier: "Overview").firstMatch
XCTAssertTrue(overview.waitForExistence(timeout: 8))
self.attachScreenshot(named: "live-gateway-control")
overview.tap()
XCTAssertTrue(controlApp.navigationBars.buttons["Control"].waitForExistence(timeout: 8))
XCTAssertTrue(controlApp.buttons["Gateway settings"].waitForExistence(timeout: 5))
self.attachScreenshot(named: "live-gateway-overview")
XCTAssertEqual(controlApp.state, .runningForeground)
}
func testManualAuthRetryUsesEditedToken() throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["OPENCLAW_IOS_RETRY_E2E"] == "1",
"Set OPENCLAW_IOS_RETRY_E2E=1 with a local token-auth Gateway on port 18920")
let token = try XCTUnwrap(ProcessInfo.processInfo.environment["OPENCLAW_IOS_RETRY_TOKEN"])
let app = XCUIApplication()
addUIInterruptionMonitor(withDescription: "Local network access") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
app.launchArguments += ["--openclaw-reset-onboarding"]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Home Network")).firstMatch.tap()
app.buttons["Continue"].tap()
let host = app.textFields["Host"]
XCTAssertTrue(host.waitForExistence(timeout: 5))
host.tap()
host.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 32) + "localhost")
let port = app.textFields["Port"]
XCTAssertTrue(port.waitForExistence(timeout: 5))
port.tap()
port.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 5) + "18920")
let unencrypted = app.buttons["Unencrypted"]
XCTAssertTrue(unencrypted.waitForExistence(timeout: 5))
unencrypted.tap()
app.buttons["Connect"].tap()
let tokenField = app.secureTextFields["Gateway Auth Token"]
XCTAssertTrue(tokenField.waitForExistence(timeout: 20))
tokenField.tap()
tokenField.typeText(token)
app.buttons["Dismiss Keyboard"].tap()
app.buttons["Retry Connection"].tap()
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 30))
self.attachScreenshot(named: "manual-auth-retry-connected")
}
func testPhotosLimitedAccess() throws {
try XCTSkipUnless(
ProcessInfo.processInfo.environment["OPENCLAW_IOS_PHOTOS_E2E"] == "1",
"Set OPENCLAW_IOS_PHOTOS_E2E=1 to exercise the system Photos prompt")
addUIInterruptionMonitor(withDescription: "Photos access") { alert in
for title in ["Limit Access…", "Select Photos…"] where alert.buttons[title].exists {
alert.buttons[title].tap()
return true
}
return false
}
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "photos-limited-access"))
let permissions = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
XCTAssertTrue(permissions.waitForExistence(timeout: 8))
permissions.tap()
let privacy = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Privacy & Access").firstMatch)
XCTAssertTrue(privacy.waitForExistence(timeout: 8))
privacy.tap()
let request = try XCTUnwrap(self.app?.buttons["privacy-access-Photos-action"])
XCTAssertTrue(request.waitForExistence(timeout: 5))
request.tap()
self.app?.tap()
// The limited picker is an out-of-process system surface without stable accessibility identifiers.
// Normalized taps are confined to this opt-in simulator test; app-owned state proves completion below.
let screen = XCUIApplication(bundleIdentifier: "com.apple.springboard")
screen.coordinate(withNormalizedOffset: CGVector(dx: 0.17, dy: 0.43)).tap()
screen.coordinate(withNormalizedOffset: CGVector(dx: 0.90, dy: 0.16)).tap()
self.app?.activate()
let limitedStatus = try XCTUnwrap(self.app?.staticTexts.matching(
NSPredicate(
format: "identifier == %@ AND label == %@",
"privacy-access-Photos-status",
"Limited")).firstMatch)
XCTAssertTrue(limitedStatus.waitForExistence(timeout: 8))
XCTAssertEqual(self.app?.buttons["privacy-access-Photos-action"].label, "Manage Access")
self.attachScreenshot(named: "photos-limited-access")
}
private func launchApp(
for target: ScreenshotTarget,
appearance: String? = "dark",
screenshotMode: Bool = true,
additionalArguments: [String] = [])
{
self.app?.terminate()
let app = XCUIApplication()
setupSnapshot(app)
app.launchArguments += [
"--openclaw-initial-tab",
target.initialTab,
"--openclaw-initial-destination",
target.initialDestination,
"--openclaw-sidebar-visibility",
"hidden",
]
if screenshotMode {
app.launchArguments.append("--openclaw-screenshot-mode")
}
app.launchArguments += additionalArguments
if let appearance {
app.launchArguments += ["--openclaw-appearance", appearance]
}
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
}
private func waitForValue(_ value: String, of element: XCUIElement) {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "value == %@", value),
object: element)
XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed)
}
private func controlDetailChatButton(above chatTab: XCUIElement) throws -> XCUIElement {
let buttons = try XCTUnwrap(self.app?.buttons.matching(NSPredicate(format: "label == 'Chat'")))
return try XCTUnwrap(buttons.allElementsBoundByIndex.first { $0.frame.maxY < chatTab.frame.minY })
}
private func launchPairedLiveGatewayApp(
initialTab: String,
initialDestination: String) throws -> XCUIApplication
{
try XCTSkipUnless(
ProcessInfo.processInfo.environment["OPENCLAW_IOS_LIVE_GATEWAY"] == "1",
"Set OPENCLAW_IOS_LIVE_GATEWAY=1 and provide a fresh setup code")
if let setupCode = ProcessInfo.processInfo.environment["OPENCLAW_IOS_LIVE_SETUP_CODE"] {
UIPasteboard.general.string = setupCode
}
let app = XCUIApplication()
addUIInterruptionMonitor(withDescription: "Local network access") { alert in
guard alert.buttons["Allow"].exists else { return false }
alert.buttons["Allow"].tap()
return true
}
app.launchArguments += [
"--openclaw-reset-onboarding",
"--openclaw-initial-tab",
initialTab,
"--openclaw-initial-destination",
initialDestination,
]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertTrue(app.buttons["Connect Manually"].waitForExistence(timeout: 8))
app.buttons["Connect Manually"].tap()
let setupCodeField = app.textFields["Enter setup code"]
XCTAssertTrue(setupCodeField.waitForExistence(timeout: 5))
setupCodeField.tap()
setupCodeField.press(forDuration: 1)
XCTAssertTrue(app.menuItems["Paste"].waitForExistence(timeout: 3))
app.menuItems["Paste"].tap()
app.buttons["Apply"].tap()
XCTAssertTrue(app.staticTexts["You're connected"].waitForExistence(timeout: 45))
app.buttons["Go to Chat"].tap()
return app
}
private func relaunchConnectedLiveGatewayApp(
initialTab: String,
initialDestination: String) -> XCUIApplication
{
self.app?.terminate()
let app = XCUIApplication()
app.launchArguments += [
"--openclaw-initial-tab",
initialTab,
"--openclaw-initial-destination",
initialDestination,
]
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
return app
}
private func sendLiveGatewayMessage(
_ text: String,
expecting replyMarker: String,
in app: XCUIApplication)
{
let input = app.textFields["chat-message-input"]
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(text)
let send = app.buttons["chat-send-message"]
XCTAssertTrue(send.waitForExistence(timeout: 3))
XCTAssertTrue(send.isEnabled)
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 3))
send.tap()
XCTAssertTrue(app.staticTexts[replyMarker].waitForExistence(timeout: 60))
XCTAssertTrue(app.staticTexts["Writing"].waitForNonExistence(timeout: 5))
}
private func assertDarkAppearanceTextVisible(
file: StaticString = #filePath,
line: UInt = #line)
{
guard let app, let image = app.screenshot().image.cgImage else {
XCTFail("App screenshot has no CGImage", file: file, line: line)
return
}
let width = image.width
let height = image.height
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let rendered = pixels.withUnsafeMutableBytes { buffer in
guard let context = CGContext(
data: buffer.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else {
return false
}
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard rendered else {
XCTFail("Could not render the appearance screenshot", file: file, line: line)
return
}
// Sample the full List content, excluding navigation/tab chrome. The regression left
// entire labels transparent while isolated row crops could still look healthy.
let sampleX = (width / 12)..<(width * 11 / 12)
let sampleY = (height / 8)..<(height * 4 / 5)
var brightPixels = 0
for y in sampleY {
for x in sampleX {
let offset = (y * width + x) * 4
if pixels[offset] > 190, pixels[offset + 1] > 190, pixels[offset + 2] > 190 {
brightPixels += 1
}
}
}
let sampledPixels = max(1, sampleX.count * sampleY.count)
XCTAssertGreaterThan(
Double(brightPixels) / Double(sampledPixels),
0.002,
"Dark appearance must keep the settings labels visibly light",
file: file,
line: line)
}
private func attachScreenshot(named name: String) {
guard let app else { return }
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func attachFullScreenScreenshot(named name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}