Files
openclaw/apps/ios/UITests/OpenClawSnapshotUITests.swift
Peter Steinberger e5fca42952 fix(apps): per-session desktop button opens that session's machine (#123412)
* fix(apps): open session desktop on its machine

* fix(ui): scope the desktop session lookup to the key's own agent

`sessions.list` has no exact-key filter, so the viewer resolves a `session=`
parameter by searching for the key and matching it exactly in the response. A
key that prefixes longer ones — `agent:main:main` alongside user-named sessions
that start the same way — could push the exact row outside a five-row page and
silently fall back to the picker.

Session keys encode their agent and the list API accepts `agentId`, so scope the
search to that agent and widen the page.

* refactor(ui): stop parking a session key in the desktop environment id

Document-mode inventory failures stashed the requested session key in
`environmentId` purely so the Retry button's non-null guard would pass, even
though document-mode retry refreshes the inventory and never reads the value.
A session key only names a machine once the inventory loads, so it now stays
out of `environmentId`, and the retry branch that ignores it runs before the
guard. Adds E2E coverage for recovering a session-preselected desktop.

* fix(ui): resolve the desktop session with an exact-key lookup

The session-preselect path searched `sessions.list` and scoped the search to
the key's own agent, but a bounded search cannot rule a key out: 25 newer
same-agent sessions sharing the requested key's prefix would push the exact row
off the page, and the viewer would report the source as unavailable for a
session that exists. `sessions.describe` is the exact-key operation and already
projects placement, so the panel calls it directly and the app-root resolver
plumbing goes away with it.

* refactor(ui): move the desktop document keyboard bridge into its own controller

`desktop-panel.ts` crossed the 700-line cap. The mobile keyboard bridge — the
padded sentinel, the value diffing that turns composed input into backspaces
and text, and the field focus/reset helpers — is a self-contained concern, so
it moves into a `DesktopMobileKeyboard` controller alongside the existing
fullscreen controller instead of taking a `max-lines` suppression.

* test(ios): drain text fields instead of assuming a delete burst lands

`testReleaseChatScreenshot` typed a 5-character probe, sent 5 deletes in one
`typeText`, then asserted the field was empty. CI dropped one synthetic
keystroke under simulator load and the assertion failed with a leftover "f".
XCUITest makes no lossless-burst guarantee, so clearing now re-sends against
whatever the field actually still holds, bounded. The two gateway-setup fields
that overtyped through the same burst use the helper for the same reason.

* fix(ui): keep the chat placement owner out of the startup chunk

`desktop-document-mode.ts` is imported by bootstrap, so importing the chat
placement owner from it pulled the chat page's dependency tree into the startup
bundle and pushed startup JS past its gzip budget (331075 B against a 330507 B
allowance). The route module now only parses the URL; resolving a session to its
machine moves next to the lazily loaded desktop panel, which is the only caller.
Startup JS is back to 329710 B.
2026-08-13 23:33:19 -07:00

1548 lines
66 KiB
Swift

import UIKit
import XCTest
@MainActor
final class OpenClawSnapshotUITests: XCTestCase {
private struct ScreenshotTarget {
let initialTab: String
let initialDestination: String
let name: String
}
private static let controlScreenshotTarget = ScreenshotTarget(
initialTab: "control",
initialDestination: "overview",
name: "01-control-connected")
private static let chatScreenshotTarget = ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "02-chat-connected")
private static let agentScreenshotTarget = ScreenshotTarget(
initialTab: "agent",
initialDestination: "agents",
name: "03-agent-connected")
private static let settingsScreenshotTarget = ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "04-settings-connected")
private static let appReadinessAccessibilityIdentifier = "RootTabs.Ready"
private var app: XCUIApplication?
override func setUpWithError() throws {
try super.setUpWithError()
continueAfterFailure = false
}
override func tearDownWithError() throws {
self.terminateCurrentApp()
try super.tearDownWithError()
}
func testReleaseControlScreenshot() {
self.captureReleaseScreenshot(Self.controlScreenshotTarget)
}
func testReleaseChatScreenshot() {
self.captureReleaseScreenshot(Self.chatScreenshotTarget) { app in
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
let keyboard = app.keyboards.firstMatch
if UIDevice.current.userInterfaceIdiom == .phone {
XCTAssertTrue(keyboard.waitForExistence(timeout: 3))
}
let focusProbe = "focus"
input.typeText(focusProbe)
XCTAssertEqual(input.value as? String, focusProbe)
self.clearTextField(input)
XCTAssertEqual(input.value as? String, "")
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
if keyboard.exists {
XCTAssertTrue(keyboard.waitForNonExistence(timeout: 3))
}
}
}
func testReleaseAgentScreenshot() {
self.captureReleaseScreenshot(Self.agentScreenshotTarget)
}
func testReleaseSettingsScreenshot() {
self.captureReleaseScreenshot(Self.settingsScreenshotTarget)
}
func testAgentsNavigateToSettingsThroughSidebar() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar navigation only")
self.launchApp(for: Self.agentScreenshotTarget)
XCTAssertTrue(self.app?.buttons["agent-status-filter-menu"].waitForExistence(timeout: 8) == true)
try self.selectSidebarDestination("Settings")
XCTAssertTrue(
self.app?.descendants(matching: .any)["settings-system-agent-row"]
.waitForExistence(timeout: 8) == true)
}
func testAutomationManagementScreenshot() {
self.launchApp(for: ScreenshotTarget(
initialTab: "control",
initialDestination: "cron",
name: "automation-management"))
XCTAssertTrue(self.app?.staticTexts["Release briefing"].waitForExistence(timeout: 8) == true)
XCTAssertTrue(self.app?.staticTexts["Weekly project review"].exists == true)
self.attachScreenshot(named: "automation-management")
}
func testSkillsManagementScreenshot() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "skills-management"))
let skills = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Skills").firstMatch)
XCTAssertTrue(skills.waitForExistence(timeout: 8))
skills.tap()
XCTAssertTrue(self.app?.staticTexts["github"].waitForExistence(timeout: 8) == true)
XCTAssertTrue(self.app?.staticTexts["calendar"].exists == true)
self.attachScreenshot(named: "skills-management")
}
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 testSidebarOverviewNavigation() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
self.launchApp(for: ScreenshotTarget(
initialTab: "control",
initialDestination: "overview",
name: "control-overview-navigation"))
XCTAssertTrue(self.app?.staticTexts["Agent session"].waitForExistence(timeout: 8) == true)
try self.selectSidebarDestination("Overview")
XCTAssertTrue(self.app?.buttons["RootTabs.Sidebar.Show"].waitForExistence(timeout: 5) == true)
XCTAssertTrue(self.app?.buttons["Gateway settings"].waitForExistence(timeout: 5) == true)
XCTAssertEqual(self.app?.state, .runningForeground)
}
func testSidebarMoreAgentsMenuShowsAvatarsAndKeepsFooterVisible() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "sidebar-more-agents"))
let showSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Show"])
XCTAssertTrue(showSidebar.waitForExistence(timeout: 8))
showSidebar.tap()
let moreAgents = try XCTUnwrap(self.app?.buttons["More Agents"])
XCTAssertTrue(moreAgents.waitForExistence(timeout: 5))
let gatewayFooter = try XCTUnwrap(self.app?.buttons.matching(
NSPredicate(format: "label CONTAINS %@", "OpenClaw Gateway")).firstMatch)
XCTAssertTrue(gatewayFooter.exists)
moreAgents.tap()
XCTAssertTrue(self.app?.buttons["Research"].waitForExistence(timeout: 5) == true)
XCTAssertTrue(self.app?.buttons["Automation"].exists == true)
self.attachScreenshot(named: "sidebar-more-agents")
}
func testSidebarSlowEdgeDragOpensFromEveryRootDestination() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
let destinations = [
"chat", "overview", "activity", "agents", "workboard", "skillWorkshop",
"instances", "sessions", "files", "dreaming", "usage", "cron", "terminal",
"docs", "settings", "gateway",
]
var testedDestinations: [String] = []
for destination in destinations {
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: destination,
name: "sidebar-slow-edge-drag-\(destination)"))
let showSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Show"])
XCTAssertTrue(showSidebar.waitForExistence(timeout: 8), destination)
XCTAssertTrue(showSidebar.isHittable, destination)
if destination == "overview" {
showSidebar.tap()
let hideSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Hide"])
self.waitForHittable(true, of: hideSidebar)
hideSidebar.tap()
self.waitForHittable(true, of: showSidebar)
}
try self.openSidebarWithSlowEdgeDrag()
if destination == "overview" {
self.attachScreenshot(named: "sidebar-slow-edge-drag-overview")
}
try self.closeSidebarWithSlowDrag()
testedDestinations.append(destination)
}
XCTAssertEqual(testedDestinations, destinations)
}
func testSidebarEdgeDragPreservesPushedScreenBackGesture() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar only")
self.launchApp(
for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "sidebar-pushed-screen-back-gesture"),
appearance: nil,
screenshotMode: false)
if self.app?.buttons["Close"].waitForExistence(timeout: 2) == true {
self.app?.buttons["Close"].tap()
}
// A clean normal-state launch can auto-route to Gateway setup.
try self.selectSidebarDestination("Settings")
let app = try XCTUnwrap(self.app)
let appearance = self.revealAppearanceSettingsRow(in: app)
XCTAssertTrue(appearance.waitForExistence(timeout: 8))
self.waitForHittable(true, of: appearance)
// Appearance is a destination-style NavigationLink, so this exercises
// the root-visibility guard rather than the typed Settings path guard.
appearance.tap()
XCTAssertTrue(self.app?.navigationBars["Appearance"].waitForExistence(timeout: 5) == true)
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.78, dy: 0.5))
start.press(
forDuration: 0.1,
thenDragTo: end,
withVelocity: .slow,
thenHoldForDuration: 0.1)
self.attachScreenshot(named: "sidebar-pushed-screen-after-back-swipe")
self.waitForHittable(false, of: app.buttons["RootTabs.Sidebar.Hide"])
self.waitForHittable(true, of: appearance)
XCTAssertFalse(app.navigationBars["Appearance"].exists)
}
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 sharingToggle = try XCTUnwrap(self.app?.buttons["settings-location-sharing-toggle"])
XCTAssertTrue(sharingToggle.waitForExistence(timeout: 5))
if sharingToggle.value as? String != "Off" {
sharingToggle.tap()
self.waitForValue("Off", of: sharingToggle)
self.waitForEnabled(sharingToggle)
}
sharingToggle.tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let prompt = springboard.alerts.firstMatch
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
self.waitForValue("On", of: sharingToggle)
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
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()
self.app?.activate()
XCTAssertTrue(
self.app?.staticTexts["Requesting iOS location permission…"].waitForNonExistence(timeout: 5) == true)
let accessLevel = try XCTUnwrap(
self.app?.descendants(matching: .any)["settings-location-access-level"])
XCTAssertTrue(accessLevel.waitForExistence(timeout: 5))
self.waitForValue("While Using the App", of: accessLevel)
let accessLevelButton = try XCTUnwrap(self.app?.buttons.matching(
NSPredicate(format: "label BEGINSWITH %@", "Access Level")).firstMatch)
XCTAssertTrue(accessLevelButton.waitForExistence(timeout: 3))
self.waitForEnabled(accessLevelButton)
accessLevelButton.tap()
let appAlwaysAction = try XCTUnwrap(self.app?.descendants(matching: .any)["Always"])
let systemAlwaysAction = springboard.descendants(matching: .any)["Always"]
let alwaysAction = appAlwaysAction.waitForExistence(timeout: 1)
? appAlwaysAction
: systemAlwaysAction
XCTAssertTrue(alwaysAction.waitForExistence(timeout: 3))
alwaysAction.tap()
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
self.waitForValue("Always", of: accessLevel)
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
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(accessLevel.waitForExistence(timeout: 5))
self.waitForValue("Always", of: accessLevel)
XCTAssertTrue(
self.app?.staticTexts["Requesting iOS location permission…"].waitForNonExistence(timeout: 5) == true)
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 sharingToggle = try XCTUnwrap(self.app?.buttons["settings-location-sharing-toggle"])
XCTAssertTrue(sharingToggle.waitForExistence(timeout: 5))
if sharingToggle.value as? String != "Off" {
sharingToggle.tap()
self.waitForValue("Off", of: sharingToggle)
self.waitForEnabled(sharingToggle)
}
sharingToggle.tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let prompt = springboard.alerts.firstMatch
XCTAssertTrue(prompt.waitForExistence(timeout: 5))
self.waitForValue("On", of: sharingToggle)
Thread.sleep(forTimeInterval: 3)
XCTAssertTrue(prompt.exists)
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()
let accessLevel = try XCTUnwrap(
self.app?.descendants(matching: .any)["settings-location-access-level"])
XCTAssertTrue(accessLevel.waitForExistence(timeout: 5))
self.waitForValue("While Using the App", of: accessLevel)
XCTAssertTrue(
self.app?.staticTexts["Requesting iOS location permission…"].waitForNonExistence(timeout: 5) == true)
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()
let relaunchedToggle = try XCTUnwrap(self.app?.buttons["settings-location-sharing-toggle"])
XCTAssertTrue(relaunchedToggle.waitForExistence(timeout: 5))
self.waitForValue("On", of: relaunchedToggle)
let relaunchedAccessLevel = try XCTUnwrap(
self.app?.descendants(matching: .any)["settings-location-access-level"])
XCTAssertTrue(relaunchedAccessLevel.waitForExistence(timeout: 5))
self.waitForValue("While Using the App", of: relaunchedAccessLevel)
}
func testGatewaySettingsOpenedFromChatUsesRootSidebarNavigation() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar navigation only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-settings-back"))
try self.openChatGatewaySettings()
let gatewayNavigationBar = try XCTUnwrap(self.app?.navigationBars["Gateway"])
XCTAssertTrue(gatewayNavigationBar.waitForExistence(timeout: 5))
XCTAssertTrue(self.app?.buttons["RootTabs.Sidebar.Show"].exists == true)
XCTAssertFalse(gatewayNavigationBar.buttons["BackButton"].exists)
self.attachScreenshot(named: "chat-gateway-root")
let showSidebar = try XCTUnwrap(self.app?.buttons["RootTabs.Sidebar.Show"])
showSidebar.tap()
let settings = try XCTUnwrap(self.app?.buttons["Settings"])
XCTAssertTrue(settings.waitForExistence(timeout: 5))
settings.tap()
XCTAssertTrue(self.app?.navigationBars["Settings"].waitForExistence(timeout: 5) == true)
XCTAssertTrue(self.app?.buttons["RootTabs.Sidebar.Show"].exists == true)
self.attachScreenshot(named: "gateway-to-settings-via-sidebar")
}
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?.buttons["Voice Wake"])
let talkMode = try XCTUnwrap(self.app?.buttons["Talk Mode"])
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
XCTAssertTrue(talkMode.exists)
if talkMode.value as? String == "On" {
talkMode.tap()
}
if voiceWake.value as? String == "On" {
voiceWake.tap()
}
voiceWake.tap()
self.waitForValue("On", of: voiceWake)
talkMode.tap()
self.waitForValue("On", of: talkMode)
talkMode.tap()
self.waitForValue("Off", of: talkMode)
XCTAssertEqual(voiceWake.value as? String, "On")
XCTAssertEqual(self.app?.state, .runningForeground)
self.attachScreenshot(named: "voice-wake-after-talk-resume")
voiceWake.tap()
self.waitForValue("Off", of: voiceWake)
}
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 app = try XCTUnwrap(self.app)
let textField = self.chatMessageInput(in: app)
XCTAssertTrue(textField.waitForExistence(timeout: 8))
let talkButton = app.buttons["chat-realtime-control"]
XCTAssertTrue(talkButton.waitForExistence(timeout: 5))
let attachmentButton = app.buttons["chat-attachment-picker"]
XCTAssertTrue(attachmentButton.waitForExistence(timeout: 5))
let dictationButton = app.buttons["chat-dictation-control"]
XCTAssertTrue(dictationButton.waitForExistence(timeout: 5))
let composerSurface = app.otherElements["chat-composer-surface"]
XCTAssertTrue(composerSurface.waitForExistence(timeout: 5))
let agentIdentity = self.agentIdentity(in: app)
XCTAssertTrue(agentIdentity.waitForExistence(timeout: 5))
XCTAssertEqual(agentIdentity.value as? String, "Collapsed")
agentIdentity.tap()
self.waitForValue("Expanded", of: agentIdentity)
let sendButton = app.buttons["chat-send-message"]
XCTAssertFalse(sendButton.exists)
XCTAssertLessThanOrEqual(agentIdentity.frame.maxY, composerSurface.frame.minY)
XCTAssertGreaterThanOrEqual(attachmentButton.frame.minX, composerSurface.frame.minX)
XCTAssertLessThanOrEqual(attachmentButton.frame.maxX, composerSurface.frame.maxX)
XCTAssertGreaterThanOrEqual(dictationButton.frame.minX, composerSurface.frame.minX)
XCTAssertLessThanOrEqual(dictationButton.frame.maxX, composerSurface.frame.maxX)
XCTAssertGreaterThanOrEqual(talkButton.frame.minX, composerSurface.frame.minX)
XCTAssertLessThanOrEqual(talkButton.frame.maxX, composerSurface.frame.maxX)
self.assertMinimumTouchTarget(attachmentButton)
self.assertMinimumTouchTarget(dictationButton)
self.assertMinimumTouchTarget(talkButton)
let compactHeight = textField.frame.height
XCTAssertLessThanOrEqual(compactHeight, 44)
XCTAssertLessThanOrEqual(abs(attachmentButton.frame.midY - dictationButton.frame.midY), 1)
XCTAssertLessThanOrEqual(abs(talkButton.frame.midY - dictationButton.frame.midY), 1)
attachmentButton.tap()
XCTAssertFalse(self.app?.buttons["Voice Memo"].exists == true)
self.app?.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
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))
self.assertMinimumTouchTarget(sendButton)
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 testChatComposerReturnInsertsNewlineWithoutSending() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-composer-return"))
let app = try XCTUnwrap(self.app)
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText("first line\nsecond line")
XCTAssertEqual(input.value as? String, "first line\nsecond line")
XCTAssertTrue(app.buttons["chat-send-message"].waitForExistence(timeout: 3))
XCTAssertFalse(app.staticTexts["first line\nsecond line"].exists)
self.attachScreenshot(named: "chat-composer-return")
}
func testVoiceNoteDraftKeepsStopAvailableDuringActiveResponse() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone voice-note composer proof only")
self.launchApp(
for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-voice-note-stop-active-response"),
additionalArguments: ["--openclaw-hold-initial-chat-run"])
let app = try XCTUnwrap(self.app)
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText("Keep this response running while I record a voice note.")
let send = app.buttons["chat-send-message"]
XCTAssertTrue(send.waitForExistence(timeout: 5))
XCTAssertTrue(send.isEnabled)
send.tap()
let stop = app.buttons["Stop response"]
XCTAssertTrue(stop.waitForExistence(timeout: 8))
XCTAssertTrue(stop.isEnabled)
let microphone = app.buttons["chat-dictation-control"]
XCTAssertTrue(microphone.waitForExistence(timeout: 5))
microphone.press(forDuration: 0.8)
let recordVoiceNote = app.buttons["Record Voice Note"]
XCTAssertTrue(recordVoiceNote.waitForExistence(timeout: 5))
recordVoiceNote.tap()
let finishVoiceNote = app.buttons["Finish voice note"]
XCTAssertTrue(finishVoiceNote.waitForExistence(timeout: 8))
finishVoiceNote.tap()
let voiceNote = app.staticTexts["Voice note"]
XCTAssertTrue(voiceNote.waitForExistence(timeout: 8))
XCTAssertTrue(stop.waitForExistence(timeout: 5))
XCTAssertTrue(stop.isEnabled)
self.attachScreenshot(named: "voice-note-stop-active-response")
stop.tap()
XCTAssertTrue(send.waitForExistence(timeout: 8))
self.waitForEnabled(send)
XCTAssertTrue(voiceNote.exists)
send.tap()
XCTAssertTrue(voiceNote.waitForNonExistence(timeout: 8))
XCTAssertTrue(
app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "I can help with"))
.firstMatch.waitForExistence(timeout: 8))
self.attachScreenshot(named: "voice-note-sent-after-stopping-response")
}
func testKeyboardOpenSendFollowsLiveEdge() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone keyboard proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "keyboard-follow"))
let app = try XCTUnwrap(self.app)
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(
"Give me a long, detailed status update covering the release plan, review feedback, " +
"open follow-ups, and the next steps for the team.")
let send = app.buttons["chat-send-message"]
XCTAssertTrue(send.waitForExistence(timeout: 5))
send.tap()
// Regression proof for #108692: with the keyboard still up, the reply must scroll into
// view above the keyboard on its own — no jump affordance, no manual scrolling.
let keyboard = app.keyboards.firstMatch
XCTAssertTrue(keyboard.exists)
let reply = app.staticTexts.matching(
NSPredicate(format: "label CONTAINS %@", "keep the mobile workflow connected to the gateway"))
.firstMatch
XCTAssertTrue(reply.waitForExistence(timeout: 8))
Thread.sleep(forTimeInterval: 1.0)
XCTAssertLessThanOrEqual(reply.frame.maxY, keyboard.frame.minY + 1)
XCTAssertFalse(app.buttons["Jump to latest reply"].exists)
}
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.map { self.agentIdentity(in: $0).waitForExistence(timeout: 8) } == true)
XCTAssertTrue(self.app?.otherElements["chat-composer-surface"].exists == true)
self.attachScreenshot(named: "chat-light")
}
func testChatKeepsLayeredCanvasInDarkAppearance() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone chat proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-dark-layered-canvas"))
XCTAssertTrue(self.app?.otherElements["chat-composer-surface"].waitForExistence(timeout: 8) == true)
self.assertChatCanvasIsNotSolidBlack()
self.attachScreenshot(named: "chat-dark-layered-canvas")
self.sendFixtureChatMessage("Check the release status and prepare the next steps.")
self.attachScreenshot(named: "chat-dark-soft-bottom-edge")
}
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()
app.launchArguments += ["--openclaw-reset-onboarding"]
app.launch()
self.app = app
XCTAssertTrue(app.buttons["Continue"].waitForExistence(timeout: 8))
app.buttons["Continue"].tap()
app.tap()
XCTAssertFalse(app.staticTexts["Allow access"].exists)
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(self.agentIdentity(in: app).waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["RootTabs.Sidebar.Show"].exists)
}
func testAppearanceUsesSettingsRow() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "appearance-compact"), appearance: nil)
let app = try XCTUnwrap(self.app)
let row = self.revealAppearanceSettingsRow(in: app)
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 testChatAndOverviewNavigateThroughSidebar() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone sidebar proof only")
self.launchApp(for: ScreenshotTarget(
initialTab: "control",
initialDestination: "overview",
name: "control-chat-return"))
let agentSession = try XCTUnwrap(self.app?.staticTexts["Agent session"])
XCTAssertTrue(agentSession.waitForExistence(timeout: 8))
self.attachScreenshot(named: "control-overview-before-chat")
try self.startNewChatFromSidebar()
XCTAssertTrue(self.app?.otherElements["chat-composer-surface"].waitForExistence(timeout: 8) == true)
self.attachScreenshot(named: "chat-return-to-overview")
try self.selectSidebarDestination("Overview")
XCTAssertTrue(agentSession.waitForExistence(timeout: 8))
self.attachScreenshot(named: "control-overview-after-chat")
let agentSessionRow = try XCTUnwrap(self.app?.buttons.matching(NSPredicate(
format: "label BEGINSWITH[c] %@",
"Molty, chat")).firstMatch)
XCTAssertTrue(agentSessionRow.waitForExistence(timeout: 8))
agentSessionRow.tap()
XCTAssertTrue(self.app?.otherElements["chat-composer-surface"].waitForExistence(timeout: 8) == true)
self.attachScreenshot(named: "chat-session-return-to-overview")
try self.selectSidebarDestination("Overview")
XCTAssertTrue(self.app?.staticTexts["Agent session"].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")
// Native context menus must finish their dismissal before teardown. Killing
// the app with this menu open can leave the next app scene inactive.
self.app?.buttons["Ready"].tap()
self.waitForValue("Ready", of: menu)
menu.tap()
let all = try XCTUnwrap(self.app?.buttons["All"])
XCTAssertTrue(all.waitForExistence(timeout: 3))
all.tap()
self.waitForValue("All", of: menu)
}
func testLiveGatewayFreshInstallSetupAndRelaunch() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone setup proof only")
let app = try self.launchPairedLiveGatewayApp(initialTab: "chat", initialDestination: "chat")
XCTAssertEqual(app.state, .runningForeground)
let controlApp = self.relaunchConnectedLiveGatewayApp(
initialTab: "control",
initialDestination: "overview")
XCTAssertTrue(controlApp.staticTexts["Agent session"].waitForExistence(timeout: 8))
XCTAssertTrue(controlApp.buttons["RootTabs.Sidebar.Show"].exists)
XCTAssertEqual(controlApp.state, .runningForeground)
}
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: "overview")
XCTAssertTrue(controlApp.staticTexts["Agent session"].waitForExistence(timeout: 8))
self.attachScreenshot(named: "live-gateway-control")
try self.selectSidebarDestination("Overview")
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()
self.clearTextField(host)
host.typeText("localhost")
let port = app.textFields["Port"]
XCTAssertTrue(port.waitForExistence(timeout: 5))
port.tap()
self.clearTextField(port)
port.typeText("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))
XCTAssertEqual(request.label, "Continue")
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")
}
func testAppleHealthDisclosureIsVisible() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "settings",
initialDestination: "settings",
name: "apple-health-disclosure"))
let permissions = try XCTUnwrap(
self.app?.buttons.containing(.staticText, identifier: "Permissions").firstMatch)
XCTAssertTrue(permissions.waitForExistence(timeout: 8))
permissions.tap()
let appleHealth = try XCTUnwrap(self.app?.staticTexts["Apple Health Summaries"])
XCTAssertTrue(appleHealth.waitForExistence(timeout: 8))
let action = try XCTUnwrap(self.app?.buttons["apple-health-summaries-action"])
XCTAssertTrue(action.waitForExistence(timeout: 5))
XCTAssertEqual(action.label, "Enable Apple Health Summaries")
let labelWidth = (action.label as NSString).size(withAttributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
]).width
XCTAssertGreaterThanOrEqual(action.frame.width, labelWidth + 24)
self.attachScreenshot(named: "apple-health-disclosure")
}
}
extension OpenClawSnapshotUITests {
private func agentIdentity(in app: XCUIApplication) -> XCUIElement {
app.otherElements.matching(identifier: "chat-agent-identity").firstMatch
}
private func revealAppearanceSettingsRow(in app: XCUIApplication) -> XCUIElement {
let row = app.descendants(matching: .any)["settings-appearance-row"]
let settingsList = app.collectionViews.firstMatch
for _ in 0..<4 {
if row.waitForExistence(timeout: 1) { break }
settingsList.swipeUp()
}
return row
}
private func readinessMarker(in app: XCUIApplication) -> XCUIElement {
app.descendants(matching: .any)[Self.appReadinessAccessibilityIdentifier]
}
private func destinationAnchor(in app: XCUIApplication, destination: String) -> XCUIElement {
switch destination {
case "overview": app.staticTexts["Agent session"]
case "chat": app.otherElements["chat-composer-surface"]
case "agents": app.buttons["agent-status-filter-menu"]
case "settings": app.descendants(matching: .any)["settings-system-agent-row"]
default: self.readinessMarker(in: app)
}
}
private func launchApp(
for target: ScreenshotTarget,
appearance: String? = "dark",
screenshotMode: Bool = true,
additionalArguments: [String] = [])
{
self.terminateCurrentApp()
let app = self.configuredApp(
for: target,
appearance: appearance,
screenshotMode: screenshotMode,
additionalArguments: additionalArguments)
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
let readiness = self.readinessMarker(in: app)
XCTAssertTrue(
readiness.waitForExistence(timeout: 8),
"OpenClaw root readiness marker did not appear")
if screenshotMode {
self.waitForValue("ready:\(target.initialDestination)", of: readiness, timeout: 8)
}
}
private func configuredApp(
for target: ScreenshotTarget,
appearance: String?,
screenshotMode: Bool,
additionalArguments: [String]) -> XCUIApplication
{
let app = XCUIApplication()
setupSnapshot(app)
app.launchArguments += [
"--openclaw-initial-tab",
target.initialTab,
"--openclaw-initial-destination",
target.initialDestination,
"--openclaw-sidebar-visibility",
"hidden",
"--openclaw-ui-test-readiness",
]
if screenshotMode {
app.launchArguments.append("--openclaw-screenshot-mode")
}
app.launchArguments += additionalArguments
if let appearance {
app.launchArguments += ["--openclaw-appearance", appearance]
}
return app
}
private func captureReleaseScreenshot(
_ target: ScreenshotTarget,
beforeCapture: ((XCUIApplication) -> Void)? = nil)
{
self.launchApp(for: target)
self.waitForReleaseScreenshotTarget(target)
guard let app = self.app else {
XCTFail("OpenClaw is not running for screenshot target \(target.name)")
return
}
beforeCapture?(app)
snapshot(target.name, timeWaitingForIdle: 5)
self.attachScreenshot(named: target.name)
}
private func waitForReleaseScreenshotTarget(_ target: ScreenshotTarget) {
guard let app = self.app else {
XCTFail("OpenClaw is not running for screenshot target \(target.name)")
return
}
let anchor = self.destinationAnchor(in: app, destination: target.initialDestination)
XCTAssertTrue(
anchor.waitForExistence(timeout: 8),
"Screenshot target \(target.name) did not render its readiness anchor")
}
private func terminateCurrentApp(
file: StaticString = #filePath,
line: UInt = #line)
{
guard let app = self.app else { return }
app.terminate()
XCTAssertTrue(
app.wait(for: .notRunning, timeout: 5),
"OpenClaw did not terminate before the next launch",
file: file,
line: line)
self.app = nil
}
private func waitForValue(
_ value: String,
of element: XCUIElement,
timeout: TimeInterval = 3)
{
XCTAssertTrue(self.element(element, hasValue: value, timeout: timeout))
}
private func assertMinimumTouchTarget(
_ element: XCUIElement,
file: StaticString = #filePath,
line: UInt = #line)
{
let minimum: CGFloat = 44
// XCUI converts screen coordinates through floating-point transforms.
// Permit rounding noise without accepting a genuinely undersized target.
let tolerance = minimum.ulp * 16
XCTAssertGreaterThanOrEqual(element.frame.width + tolerance, minimum, file: file, line: line)
XCTAssertGreaterThanOrEqual(element.frame.height + tolerance, minimum, file: file, line: line)
}
private func element(_ element: XCUIElement, hasValue value: String, timeout: TimeInterval) -> Bool {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "value == %@", value),
object: element)
return XCTWaiter.wait(for: [expectation], timeout: timeout) == .completed
}
private func waitForEnabled(_ element: XCUIElement) {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "enabled == true"),
object: element)
XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed)
}
private func waitForHittable(_ isHittable: Bool, of element: XCUIElement) {
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "hittable == %@", NSNumber(value: isHittable)),
object: element)
XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 5), .completed)
}
private func tapSidebarReveal(
in app: XCUIApplication,
file: StaticString = #filePath,
line: UInt = #line)
{
let reveal = app.buttons["RootTabs.Sidebar.Show"]
XCTAssertTrue(reveal.waitForExistence(timeout: 5), file: file, line: line)
if reveal.isHittable {
reveal.tap()
return
}
// iOS 18 can mirror the nested SwiftUI button's accessibility frame
// while the visible control remains at the navigation bar's leading edge.
let navigationBar = app.navigationBars.firstMatch
XCTAssertTrue(navigationBar.waitForExistence(timeout: 5), file: file, line: line)
let appFrame = app.frame
let navigationFrame = navigationBar.frame
let coordinate = app.coordinate(withNormalizedOffset: CGVector(
dx: (navigationFrame.minX + 38) / appFrame.width,
dy: (navigationFrame.minY + 22) / appFrame.height))
coordinate.tap()
}
private func openSidebarWithSlowEdgeDrag(
file: StaticString = #filePath,
line: UInt = #line) throws
{
let app = try XCTUnwrap(self.app, file: file, line: line)
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.5))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.78, dy: 0.5))
start.press(
forDuration: 0.1,
thenDragTo: end,
withVelocity: .slow,
thenHoldForDuration: 0.1)
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Hide"])
}
private func closeSidebarWithSlowDrag(
file: StaticString = #filePath,
line: UInt = #line) throws
{
let app = try XCTUnwrap(self.app, file: file, line: line)
// Start inside the exposed sidebar, not on the translated detail card.
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.72, dy: 0.5))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
start.press(
forDuration: 0.1,
thenDragTo: end,
withVelocity: .slow,
thenHoldForDuration: 0.1)
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Show"])
}
private func selectSidebarDestination(
_ title: String,
file: StaticString = #filePath,
line: UInt = #line) throws
{
let app = try XCTUnwrap(self.app, file: file, line: line)
let hideSidebar = app.buttons["RootTabs.Sidebar.Hide"]
if !hideSidebar.isHittable {
self.tapSidebarReveal(in: app, file: file, line: line)
self.waitForHittable(true, of: hideSidebar)
}
let destination = app.buttons.matching(NSPredicate(
format: "label == %@ OR label BEGINSWITH %@",
title,
"\(title),")).firstMatch
XCTAssertTrue(destination.waitForExistence(timeout: 5), file: file, line: line)
self.waitForHittable(true, of: destination)
destination.tap()
self.waitForHittable(false, of: hideSidebar)
XCTAssertTrue(app.buttons["RootTabs.Sidebar.Show"].waitForExistence(timeout: 5), file: file, line: line)
}
private func startNewChatFromSidebar(
file: StaticString = #filePath,
line: UInt = #line) throws
{
let app = try XCTUnwrap(self.app, file: file, line: line)
let hideSidebar = app.buttons["RootTabs.Sidebar.Hide"]
if !hideSidebar.isHittable {
self.tapSidebarReveal(in: app, file: file, line: line)
self.waitForHittable(true, of: hideSidebar)
}
let newChat = app.buttons["New Chat"]
XCTAssertTrue(newChat.waitForExistence(timeout: 5), file: file, line: line)
XCTAssertTrue(newChat.isEnabled, file: file, line: line)
self.waitForHittable(true, of: newChat)
newChat.tap()
self.waitForHittable(false, of: hideSidebar)
XCTAssertTrue(app.buttons["RootTabs.Sidebar.Show"].waitForExistence(timeout: 5), file: file, line: line)
}
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 = self.chatMessageInput(in: app)
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.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(app.staticTexts[replyMarker].waitForExistence(timeout: 60))
XCTAssertTrue(app.staticTexts["Writing"].waitForNonExistence(timeout: 5))
}
private func openChatGatewaySettings(
file: StaticString = #filePath,
line: UInt = #line) throws
{
let actions = try XCTUnwrap(self.app?.buttons["Chat actions"], file: file, line: line)
XCTAssertTrue(actions.waitForExistence(timeout: 8), file: file, line: line)
actions.tap()
let app = try XCTUnwrap(self.app, file: file, line: line)
let gatewaySettings = app.buttons["chat-gateway-settings"]
let actionsMenu = app.collectionViews.firstMatch
for _ in 0..<3 {
if gatewaySettings.waitForExistence(timeout: 1) { break }
actionsMenu.swipeUp()
}
XCTAssertTrue(gatewaySettings.waitForExistence(timeout: 3), file: file, line: line)
gatewaySettings.tap()
}
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 assertChatCanvasIsNotSolidBlack(
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 chat screenshot", file: file, line: line)
return
}
// The fixture leaves this canvas region empty. A pure-black host makes
// both system scroll-edge effects collapse into hard black clipping.
let sampleX = (width / 8)..<(width * 7 / 8)
let sampleY = (height * 2 / 5)..<(height * 7 / 10)
var layeredPixels = 0
for y in sampleY {
for x in sampleX {
let offset = (y * width + x) * 4
if pixels[offset] > 3 || pixels[offset + 1] > 3 || pixels[offset + 2] > 3 {
layeredPixels += 1
}
}
}
let sampledPixels = max(1, sampleX.count * sampleY.count)
XCTAssertGreaterThan(
Double(layeredPixels) / Double(sampledPixels),
0.95,
"Dark Chat must retain a layered canvas behind its translucent edge chrome",
file: file,
line: line)
}
private func sendFixtureChatMessage(_ text: String) {
guard let app else {
XCTFail("Fixture app is unavailable")
return
}
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(text)
let send = app.buttons["chat-send-message"]
XCTAssertTrue(send.waitForExistence(timeout: 3))
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForNonExistence(timeout: 3))
send.tap()
XCTAssertTrue(app.staticTexts[text].waitForExistence(timeout: 5))
XCTAssertTrue(
app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "I can help with"))
.firstMatch.waitForExistence(timeout: 5))
}
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 chatMessageInput(in app: XCUIApplication) -> XCUIElement {
app.descendants(matching: .any)["chat-message-input"]
}
/// A burst of synthetic key events can drop a keystroke under simulator load, so one
/// delete-per-character `typeText` does not reliably empty a field. Re-send against
/// whatever is actually left instead of assuming the first burst landed in full.
private func clearTextField(_ element: XCUIElement, attempts: Int = 4) {
for _ in 0 ..< attempts {
let value = element.value as? String ?? ""
if value.isEmpty {
return
}
element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count))
}
}
private func attachFullScreenScreenshot(named name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}