test: add ios screenshot automation

This commit is contained in:
joshavant
2026-06-15 16:52:08 +02:00
parent 377f6181a9
commit 5a2641fc41
9 changed files with 450 additions and 0 deletions

View File

@@ -63,6 +63,7 @@ Release behavior:
- Local development uses the canonical `ai.openclawfoundation.app*` bundle IDs when the OpenClaw team is available, and unique `ai.openclawfoundation.app.test.*` bundle IDs only for non-canonical fallback teams.
- Beta release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- App Store screenshots use `pnpm ios:screenshots`, which drives Fastlane Snapshot through a deterministic connected screenshot fixture instead of a live gateway.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
- `apps/ios/CHANGELOG.md` is the iOS-only changelog and release-note source.

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClawUITests</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
</dict>
</plist>

View File

@@ -0,0 +1,58 @@
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()
self.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)
}
}
private func launchApp(for target: ScreenshotTarget) {
self.app?.terminate()
let app = XCUIApplication()
setupSnapshot(app, waitForAnimations: false)
app.launchArguments += [
"--openclaw-screenshot-mode",
"--openclaw-initial-tab",
target.initialTab,
"--openclaw-initial-destination",
target.initialDestination,
"--openclaw-sidebar-visibility",
"hidden",
]
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
}
}

View File

@@ -0,0 +1,309 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
var deviceLanguage = ""
var locale = ""
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
return "Can't use Snapshot on a physical device."
}
}
}
@objcMembers
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch let error {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
}
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch let error {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { context in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return self.containing(isNetworkLoadingIndicator)
}
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return self.containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
return numberA...numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.27]

View File

@@ -19,3 +19,6 @@
# Deliver toggles (off by default)
# DELIVER_METADATA=1
# DELIVER_SCREENSHOTS=1
# Screenshot generation
# OPENCLAW_SNAPSHOT_DEVICES=iPhone 16 Pro Max,iPad Pro 13-inch (M4)

View File

@@ -77,6 +77,14 @@ Archive locally without upload:
pnpm ios:beta:archive
```
Generate deterministic App Store screenshots:
```bash
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to TestFlight:
```bash

View File

@@ -37,6 +37,14 @@ schemes:
test:
targets:
- OpenClawLogicTests
OpenClawUITests:
shared: true
build:
targets:
OpenClawUITests: all
test:
targets:
- OpenClawUITests
targets:
OpenClaw:
@@ -360,3 +368,32 @@ targets:
CFBundleDisplayName: OpenClawLogicTests
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
OpenClawUITests:
type: bundle.ui-testing
platform: iOS
configFiles:
Debug: Signing.xcconfig
Release: Signing.xcconfig
sources:
- path: UITests
dependencies:
- target: OpenClaw
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID).ui-tests"
SDKROOT: iphoneos
SUPPORTED_PLATFORMS: "iphonesimulator iphoneos"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TEST_TARGET_NAME: OpenClaw
SWIFT_VERSION: "5.0"
info:
path: UITests/Info.plist
properties:
CFBundleDisplayName: OpenClawUITests
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"

View File

@@ -1557,6 +1557,7 @@
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:run": "bash scripts/ios-run.sh",
"ios:screenshots": "bash scripts/ios-screenshots.sh",
"ios:version": "node --import tsx scripts/ios-version.ts --json",
"ios:version:check": "node --import tsx scripts/ios-sync-versioning.ts --check",
"ios:version:pin": "node --import tsx scripts/ios-pin-version.ts",

9
scripts/ios-screenshots.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
(
cd "${ROOT_DIR}/apps/ios"
fastlane ios screenshots
)