Files
adolf/apps/ios/UITests/OpenClawSnapshotUITests.swift
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

897 lines
39 KiB
Swift
Raw Permalink 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"].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 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 copyPairCommand = app.buttons["onboarding-copy-pair-command"]
XCTAssertTrue(copyPairCommand.waitForExistence(timeout: 8))
copyPairCommand.tap()
XCTAssertEqual(copyPairCommand.label, "Pair command copied")
self.attachScreenshot(named: "onboarding-copy-pair-command")
app.buttons["Set Up Manually"].tap()
let setupCode = app.textFields["Paste setup code"]
XCTAssertTrue(setupCode.waitForExistence(timeout: 5))
setupCode.tap()
setupCode.typeText("APPLE-REVIEW-DEMO")
app.buttons["Done"].tap()
app.buttons["Apply Setup Code"].tap()
XCTAssertTrue(app.staticTexts["Connected"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Apple Review Demo Gateway"].exists)
XCTAssertFalse(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["Set Up Manually"].waitForExistence(timeout: 8))
app.buttons["Set Up Manually"].tap()
let developerMode = app.buttons["Developer mode"]
if developerMode.value as? String != "On" {
developerMode.tap()
}
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Same Machine (Dev)")).firstMatch.tap()
app.buttons["Continue"].tap()
let port = app.textFields["Port"]
XCTAssertTrue(port.waitForExistence(timeout: 5))
port.tap()
port.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 5) + "18920")
app.buttons["Connect"].tap()
let tokenField = app.secureTextFields["Gateway Auth Token"]
XCTAssertTrue(tokenField.waitForExistence(timeout: 20))
tokenField.tap()
tokenField.typeText(token)
app.buttons["Done"].tap()
app.buttons["Retry Connection"].tap()
XCTAssertTrue(app.staticTexts["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["Set Up Manually"].waitForExistence(timeout: 8))
app.buttons["Set Up Manually"].tap()
let setupCodeField = app.textFields["Paste 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["Done"].tap()
app.buttons["Apply Setup Code"].tap()
XCTAssertTrue(app.staticTexts["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)
}
}