fix(ios): stabilize Apple UI validation across runtimes (#116487)

* test(ios): align source guards with current contracts

* test(ios): stabilize cross-version UI automation

* test(ios): harden typography boundary audit

* test(ios): synchronize deterministic UI launches
This commit is contained in:
Vincent Koc
2026-07-31 01:30:46 +08:00
committed by GitHub
parent 7b581eccc1
commit 8a12343efe
6 changed files with 284 additions and 142 deletions

View File

@@ -182,7 +182,11 @@ struct AgentAutomationModelsTests {
#expect(source.contains("pendingRunRegistry"))
#expect(!source.contains("self.pendingRunID = nil"))
#expect(source.contains("self.pendingRunRegistry.release(jobID: self.job.id, runID: runID)"))
#expect(source.contains("\"expectedProcessInstanceId\": processInstanceID"))
#expect(Self.containsDictionaryAssignment(
dictionary: "runParams",
key: "expectedProcessInstanceId",
value: "processInstanceID",
in: source))
#expect(source.contains("guard self.pendingRunID == runID else { return }"))
#expect(models.contains("expectedConfigRevision"))
#expect(source.contains("Delete Automation"))
@@ -202,7 +206,11 @@ struct AgentAutomationModelsTests {
#expect(cronSource.contains("self.pendingCronRuns.reserve(jobID: jobID, runID: runID)"))
#expect(cronSource.contains("entries.contains(where: { $0.runid == runID })"))
#expect(cronSource.contains("method: \"system.info\""))
#expect(cronSource.contains("\"expectedProcessInstanceId\": processInstanceID"))
#expect(Self.containsDictionaryAssignment(
dictionary: "runParams",
key: "expectedProcessInstanceId",
value: "processInstanceID",
in: cronSource))
#expect(cronSource.contains("guard currentInstanceID == processInstanceID else"))
#expect(cronSource
.contains(
@@ -246,4 +254,22 @@ struct AgentAutomationModelsTests {
.appendingPathComponent("Sources")
.appendingPathComponent(path)
}
private static func containsDictionaryAssignment(
dictionary: String,
key: String,
value: String,
in source: String) -> Bool
{
let pattern = [
#"\b"#,
NSRegularExpression.escapedPattern(for: dictionary),
#"\s*\[\s*""#,
NSRegularExpression.escapedPattern(for: key),
#""\s*\]\s*=\s*"#,
NSRegularExpression.escapedPattern(for: value),
#"\b"#,
].joined()
return source.range(of: pattern, options: .regularExpression) != nil
}
}

View File

@@ -2183,7 +2183,7 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
}
}
@Test @MainActor func `forget connected gateway disconnects and repeats device auth cleanup`() async throws {
@Test @MainActor func `forget connected gateway waits for disconnect before device auth cleanup`() async throws {
let registryIsolation = GatewayRegistryTestIsolation()
defer { registryIsolation.restore() }
let service = GatewaySettingsStore._testGatewayService
@@ -2200,10 +2200,19 @@ private func waitForActiveGateway(stableID: String, appModel: NodeAppModel) asyn
let connectedID = "manual|connected.example.com|443"
let selectedID = "manual|selected.example.com|443"
saveActiveManualGateway(host: "selected.example.com", port: 443, useTLS: true, stableID: selectedID)
#expect(GatewaySettingsStore.upsertGatewayRegistryEntry(.init(
stableID: connectedID,
kind: .manual,
name: "connected.example.com:443",
host: "connected.example.com",
port: 443,
useTLS: true,
lastConnectedAtMs: nil)))
let appModel = NodeAppModel()
try appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig(
url: #require(URL(string: "wss://connected.example.com")),
stableID: connectedID))
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let identity = DeviceIdentityStore.loadOrCreate()
let resetRelease = AsyncStream<Void>.makeStream()

View File

@@ -426,6 +426,7 @@ import Testing
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let staleProblem = GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
@@ -444,7 +445,9 @@ import Testing
await controller.connectManual(host: host, port: port, useTLS: true)
#expect(controller.pendingTrustPrompt == nil)
#expect(appModel.lastGatewayProblem == nil)
#expect(appModel.lastGatewayProblem == staleProblem)
#expect(!appModel.gatewayPairingPaused)
#expect(appModel.gatewayPairingRequestId == nil)
#expect(appModel.gatewayStatusText.contains("TLS fingerprint verification timed out"))
#expect(appModel.gatewayStatusText.contains("\(host):\(port)"))
}

View File

@@ -400,6 +400,52 @@ struct OpenClawTypographyTests {
#expect(offenders.isEmpty, Comment(rawValue: offenders.joined(separator: "\n")))
}
@Test func `font boundary scanner permits inheritance and rejects system overrides`() {
let source = """
Text("Inherited")
Text("Branded")
.padding()
.font(OpenClawType.body)
Text("System")
.padding()
.font(.body)
Button("System") {}
.font(.headline)
VStack {
Text("Inherited system")
}
.font(.body)
Text("Trailing closure")
.background { Color.clear }
.font(.caption)
Text("Later override")
.font(OpenClawType.body)
.font(.title)
NavigationStack {
Text("Custom boundary")
}
.font(.footnote)
Button {
action()
} label: {
Text("Closure-only control")
}
.font(.headline)
Image(systemName: "checkmark")
.font(.system(size: 14))
"""
let offenders = Self.unbrandedTextCallOffenders(in: source, relativePath: "Sample.swift")
#expect(offenders.count == 7)
#expect(offenders[0].contains(".font(.body)"))
#expect(offenders[1].contains(".font(.headline)"))
#expect(offenders[2].contains(".font(.body)"))
#expect(offenders[3].contains(".font(.caption)"))
#expect(offenders[4].contains(".font(.title)"))
#expect(offenders[5].contains(".font(.footnote)"))
#expect(offenders[6].contains(".font(.headline)"))
}
@Test func `accessibility metadata text does not require visual typography`() throws {
let accessibilityTextSamples = [
".accessibilityLabel(Text(title))",
@@ -532,41 +578,38 @@ struct OpenClawTypographyTests {
}
private static func unbrandedTextCallOffenders() throws -> [String] {
let fontTokens = ["OpenClawType", "OpenClawChatTypography"]
// Accessibility-only Text is spoken, never rendered, so no branded font applies.
let allowedFragments = [".navigationTitle(", ".alert(\"", ".tabItem { Label("]
return try self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
try self.swiftSourcesForTypographyAudit().flatMap { url -> [String] in
let source = try String(contentsOf: url, encoding: .utf8)
let lines = source.components(separatedBy: .newlines)
let accessibilityMetadataTextLines = self.accessibilityMetadataTextLines(in: lines)
return lines.indices.compactMap { idx -> String? in
let rawLine = lines[idx]
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard !line.hasPrefix("//") else { return nil }
guard !allowedFragments.contains(where: rawLine.contains) else { return nil }
let window = lines[idx..<min(lines.count, idx + 12)].joined(separator: "\n")
let hasLocalFont = fontTokens.contains { window.contains($0) }
|| self.hasAllowedBrandedFontParameter(window, line: rawLine, in: url)
if self.isTextOrLabelCall(rawLine),
!accessibilityMetadataTextLines.contains(idx),
!hasLocalFont
{
return "\(self.relativePath(url)):\(idx + 1): \(line)"
}
if self.isShorthandControlCall(rawLine), !hasLocalFont {
return "\(self.relativePath(url)):\(idx + 1): \(line)"
}
return nil
}
return self.unbrandedTextCallOffenders(in: source, relativePath: self.relativePath(url))
}
}
private static func unbrandedTextCallOffenders(in source: String, relativePath: String) -> [String] {
let fontTokens = ["OpenClawType", "OpenClawChatTypography", "typography."]
let sourceBytes = Array(source.utf8)
let code = self.maskedSwiftCode(source)
let imageFontRanges = Set(self.directImageFontModifierRanges(in: code))
var offenders: [String] = []
for fontRange in self.fontModifierRanges(in: code) {
let fontCall = String(decoding: sourceBytes[fontRange], as: UTF8.self)
let hasBrandedFont = fontTokens.contains { fontCall.contains($0) }
|| self.hasAllowedBrandedFontParameter(fontCall, relativePath: relativePath)
guard !hasBrandedFont, !imageFontRanges.contains(fontRange) else { continue }
let fontLine = code[..<fontRange.lowerBound].count(where: { $0 == 10 })
offenders.append(
"\(relativePath):\(fontLine + 1): \(fontCall.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return offenders
}
private static let textOrLabelCallPattern = try! NSRegularExpression(pattern: #"\b(Text|Label)\s*\("#)
private static func isTextOrLabelCall(_ line: String) -> Bool {
line.range(of: #"\b(Text|Label)\s*\("#, options: .regularExpression) != nil
self.textOrLabelCallPattern.firstMatch(
in: line,
range: NSRange(line.startIndex..<line.endIndex, in: line)) != nil
}
private static func isAccessibilityMetadataTextCall(at idx: Int, in lines: [String]) -> Bool {
@@ -623,9 +666,11 @@ struct OpenClawTypographyTests {
at offset: Int,
in code: [UInt8]) -> Int?
{
guard offset + token.count <= code.count,
guard let firstByte = token.first,
offset + token.count <= code.count,
code[offset] == firstByte,
code[offset..<(offset + token.count)].elementsEqual(token),
token.first == 46 || offset == 0 || !self.isSwiftIdentifierByte(code[offset - 1])
firstByte == 46 || offset == 0 || !self.isSwiftIdentifierByte(code[offset - 1])
else { return nil }
let afterToken = offset + token.count
guard afterToken == code.count || !self.isSwiftIdentifierByte(code[afterToken]) else { return nil }
@@ -642,11 +687,20 @@ struct OpenClawTypographyTests {
}
private static func matchingParenthesis(in code: [UInt8], openingAt offset: Int) -> Int? {
self.matchingDelimiter(in: code, openingAt: offset, opening: 40, closing: 41)
}
private static func matchingDelimiter(
in code: [UInt8],
openingAt offset: Int,
opening: UInt8,
closing: UInt8) -> Int?
{
var depth = 0
for cursor in offset..<code.count {
if code[cursor] == 40 {
if code[cursor] == opening {
depth += 1
} else if code[cursor] == 41 {
} else if code[cursor] == closing {
depth -= 1
if depth == 0 { return cursor }
}
@@ -733,22 +787,47 @@ struct OpenClawTypographyTests {
return code
}
private static func isShorthandControlCall(_ line: String) -> Bool {
line.range(
of: #"\b(Button|Link|Picker|Toggle|TextField|SecureField|Menu|DisclosureGroup|LabeledContent)\s*\(""#,
options: .regularExpression) != nil
private static func fontModifierRanges(in code: [UInt8]) -> [Range<Int>] {
let token = Array(".font".utf8)
return code.indices.compactMap { offset in
guard let opening = self.callOpeningParenthesis(after: token, at: offset, in: code),
let closing = self.matchingParenthesis(in: code, openingAt: opening)
else { return nil }
return offset..<(closing + 1)
}
}
private static func hasAllowedBrandedFontParameter(_ window: String, line: String, in url: URL) -> Bool {
switch self.relativePath(url) {
private static func directImageFontModifierRanges(in code: [UInt8]) -> [Range<Int>] {
let imageToken = Array("Image".utf8)
let fontToken = Array(".font".utf8)
return code.indices.compactMap { offset in
guard let imageOpening = self.callOpeningParenthesis(after: imageToken, at: offset, in: code),
let imageClosing = self.matchingParenthesis(in: code, openingAt: imageOpening)
else { return nil }
let fontStart = self.skippingWhitespace(in: code, from: imageClosing + 1)
guard let fontOpening = self.callOpeningParenthesis(after: fontToken, at: fontStart, in: code),
let fontClosing = self.matchingParenthesis(in: code, openingAt: fontOpening)
else { return nil }
return fontStart..<(fontClosing + 1)
}
}
private static func skippingWhitespace(in code: [UInt8], from offset: Int) -> Int {
var cursor = offset
while cursor < code.count, code[cursor] == 9 || code[cursor] == 10 || code[cursor] == 13 || code[cursor] == 32 {
cursor += 1
}
return cursor
}
private static func hasAllowedBrandedFontParameter(_ fontCall: String, relativePath: String) -> Bool {
switch relativePath {
case "apps/ios/Sources/Design/OpenClawProComponents.swift":
line.contains("Text(key)") ||
line.contains("Text(verbatim: value)") ||
window.contains(".font(self.titleFont)") ||
window.contains(".font(self.subtitleFont)")
fontCall.contains(".font(self.titleFont)") ||
fontCall.contains(".font(self.subtitleFont)")
case "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift":
// Qualified values are composed here, then styled at the prose render boundary.
line.contains("SwiftUI.Text(") || window.contains(".font(self.font)")
fontCall.contains(".font(self.font)") || fontCall.contains(".font(font)")
default:
false
}

View File

@@ -192,16 +192,17 @@ final class OpenClawSnapshotUITests: XCTestCase {
if self.app?.buttons["Close"].waitForExistence(timeout: 2) == true {
self.app?.buttons["Close"].tap()
}
let appearance = try XCTUnwrap(self.app?.buttons["settings-appearance-row"])
// 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)
try self.verifyLeadingEdgeVerticalScrollPassesThrough(marker: 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 app = try XCTUnwrap(self.app)
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(
@@ -417,30 +418,6 @@ final class OpenClawSnapshotUITests: XCTestCase {
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)
let settingsList = try XCTUnwrap(self.app?.collectionViews.firstMatch)
for _ in 0..<4 {
if diagnostics.waitForExistence(timeout: 1) { break }
settingsList.swipeUp()
}
XCTAssertTrue(diagnostics.waitForExistence(timeout: 5))
diagnostics.tap()
let voiceWakeStatus = try XCTUnwrap(
self.app?.staticTexts["Voice Wake isnt supported on Simulator"])
XCTAssertTrue(voiceWakeStatus.waitForExistence(timeout: 5))
let diagnosticsNavigationBar = try XCTUnwrap(self.app?.navigationBars["Diagnostics"])
diagnosticsNavigationBar.buttons["BackButton"].tap()
for _ in 0..<4 {
if voiceSettings.waitForExistence(timeout: 1) { break }
settingsList.swipeDown()
}
XCTAssertTrue(voiceSettings.waitForExistence(timeout: 5))
voiceSettings.tap()
XCTAssertTrue(voiceWake.waitForExistence(timeout: 5))
voiceWake.tap()
self.waitForValue("Off", of: voiceWake)
}
@@ -462,7 +439,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(dictationButton.waitForExistence(timeout: 5))
let composerSurface = try XCTUnwrap(app?.otherElements["chat-composer-surface"])
XCTAssertTrue(composerSurface.waitForExistence(timeout: 5))
let agentIdentity = try XCTUnwrap(app?.otherElements["chat-agent-identity"])
let agentIdentity = try self.agentIdentity(in: XCTUnwrap(self.app))
XCTAssertTrue(agentIdentity.waitForExistence(timeout: 5))
XCTAssertEqual(agentIdentity.value as? String, "Collapsed")
agentIdentity.tap()
@@ -601,7 +578,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
name: "chat-light"),
appearance: "light")
XCTAssertTrue(self.app?.otherElements["chat-agent-identity"].waitForExistence(timeout: 8) == true)
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")
}
@@ -709,7 +686,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
self.attachScreenshot(named: "onboarding-connected-go-to-chat")
app.buttons["Go to Chat"].tap()
XCTAssertTrue(app.otherElements["chat-agent-identity"].waitForExistence(timeout: 5))
XCTAssertTrue(self.agentIdentity(in: app).waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["RootTabs.Sidebar.Show"].exists)
}
@@ -719,7 +696,8 @@ final class OpenClawSnapshotUITests: XCTestCase {
initialDestination: "settings",
name: "appearance-compact"), appearance: nil)
let row = try XCTUnwrap(self.app?.buttons["settings-appearance-row"])
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)
@@ -990,12 +968,41 @@ final class OpenClawSnapshotUITests: XCTestCase {
let appleHealth = try XCTUnwrap(self.app?.staticTexts["Apple Health Summaries"])
XCTAssertTrue(appleHealth.waitForExistence(timeout: 8))
XCTAssertTrue(self.app?.staticTexts["Apple Health"].exists == true)
let action = try XCTUnwrap(self.app?.buttons["apple-health-summaries-action"])
XCTAssertTrue(action.waitForExistence(timeout: 5))
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",
@@ -1012,11 +1019,13 @@ extension OpenClawSnapshotUITests {
app.launch()
self.app = app
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8))
let readiness = app.descendants(matching: .any)[Self.appReadinessAccessibilityIdentifier]
let readiness = self.readinessMarker(in: app)
XCTAssertTrue(
readiness.waitForExistence(timeout: 8),
"OpenClaw root readiness marker did not appear")
self.waitForValue("ready:\(target.initialDestination)", of: readiness, timeout: 8)
if screenshotMode {
self.waitForValue("ready:\(target.initialDestination)", of: readiness, timeout: 8)
}
}
private func configuredApp(
@@ -1058,16 +1067,7 @@ extension OpenClawSnapshotUITests {
XCTFail("OpenClaw is not running for screenshot target \(target.name)")
return
}
let readiness = app.descendants(matching: .any)[Self.appReadinessAccessibilityIdentifier]
self.waitForValue("ready:\(target.initialDestination)", of: readiness, timeout: 8)
let anchor: XCUIElement = switch target.initialDestination {
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: readiness
}
let anchor = self.destinationAnchor(in: app, destination: target.initialDestination)
XCTAssertTrue(
anchor.waitForExistence(timeout: 8),
"Screenshot target \(target.name) did not render its readiness anchor")
@@ -1129,6 +1129,30 @@ extension OpenClawSnapshotUITests {
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
@@ -1145,35 +1169,6 @@ extension OpenClawSnapshotUITests {
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Hide"])
}
private func verifyLeadingEdgeVerticalScrollPassesThrough(
marker: XCUIElement,
file: StaticString = #filePath,
line: UInt = #line) throws
{
let app = try XCTUnwrap(self.app, file: file, line: line)
XCTAssertTrue(marker.waitForExistence(timeout: 5), file: file, line: line)
let initialY = marker.frame.minY
let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.78))
let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.01, dy: 0.22))
start.press(
forDuration: 0.1,
thenDragTo: end,
withVelocity: .slow,
thenHoldForDuration: 0.1)
XCTAssertLessThan(marker.frame.minY, initialY - 20, file: file, line: line)
self.waitForHittable(true, of: app.buttons["RootTabs.Sidebar.Show"])
let restoreStart = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.22))
let restoreEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.78))
restoreStart.press(
forDuration: 0.1,
thenDragTo: restoreEnd,
withVelocity: .fast,
thenHoldForDuration: 0.1)
self.waitForHittable(true, of: marker)
}
private func closeSidebarWithSlowDrag(
file: StaticString = #filePath,
line: UInt = #line) throws
@@ -1199,10 +1194,7 @@ extension OpenClawSnapshotUITests {
let app = try XCTUnwrap(self.app, file: file, line: line)
let hideSidebar = app.buttons["RootTabs.Sidebar.Hide"]
if !hideSidebar.isHittable {
let showSidebar = app.buttons["RootTabs.Sidebar.Show"]
XCTAssertTrue(showSidebar.waitForExistence(timeout: 5), file: file, line: line)
XCTAssertTrue(showSidebar.isHittable, file: file, line: line)
showSidebar.tap()
self.tapSidebarReveal(in: app, file: file, line: line)
self.waitForHittable(true, of: hideSidebar)
}
@@ -1211,7 +1203,7 @@ extension OpenClawSnapshotUITests {
title,
"\(title),")).firstMatch
XCTAssertTrue(destination.waitForExistence(timeout: 5), file: file, line: line)
XCTAssertTrue(destination.isHittable, file: file, line: line)
self.waitForHittable(true, of: destination)
destination.tap()
self.waitForHittable(false, of: hideSidebar)
@@ -1225,17 +1217,14 @@ extension OpenClawSnapshotUITests {
let app = try XCTUnwrap(self.app, file: file, line: line)
let hideSidebar = app.buttons["RootTabs.Sidebar.Hide"]
if !hideSidebar.isHittable {
let showSidebar = app.buttons["RootTabs.Sidebar.Show"]
XCTAssertTrue(showSidebar.waitForExistence(timeout: 5), file: file, line: line)
XCTAssertTrue(showSidebar.isHittable, file: file, line: line)
showSidebar.tap()
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)
XCTAssertTrue(newChat.isHittable, file: file, line: line)
self.waitForHittable(true, of: newChat)
newChat.tap()
self.waitForHittable(false, of: hideSidebar)

View File

@@ -12,7 +12,15 @@ final class ShareExtensionUITests: XCTestCase {
func testComposeCardStatesInShareSheet() throws {
let photos = XCUIApplication(bundleIdentifier: "com.apple.mobileslideshow")
addUIInterruptionMonitor(withDescription: "Photos notifications") { alert in
for title in ["Dont Allow", "Don't Allow", "Not Now"] where alert.buttons[title].exists {
alert.buttons[title].tap()
return true
}
return false
}
photos.launch()
photos.tap()
// Photos intermittently shows a "What's New" splash on launch.
let continueButton = photos.buttons["Continue"].firstMatch
@@ -24,19 +32,13 @@ final class ShareExtensionUITests: XCTestCase {
// no photo detail (with its Share button) is already on screen.
let shareButton = photos.buttons["Share"].firstMatch
if !shareButton.waitForExistence(timeout: 5) {
let firstPhoto = photos.scrollViews.images.firstMatch
guard firstPhoto.waitForExistence(timeout: 10) else {
throw XCTSkip("Photos library has no images; seed one with `xcrun simctl addmedia`.")
}
// Grid thumbnails report as non-hittable while the transition settles;
// a coordinate tap sidesteps the hittability check reliably.
firstPhoto.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
try self.openFirstPhoto(in: photos)
if !shareButton.waitForExistence(timeout: 5) {
firstPhoto.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(shareButton.waitForExistence(timeout: 10), "Photo detail share button missing")
try self.openFirstPhoto(in: photos)
}
XCTAssertTrue(shareButton.waitForExistence(timeout: 10), "Photo detail share button missing")
}
shareButton.tap()
self.tapShareButton(shareButton, in: photos)
// Target the share-sheet app cell explicitly; label-only matching can hit
// other OpenClaw builds installed on the same simulator.
@@ -87,6 +89,40 @@ final class ShareExtensionUITests: XCTestCase {
XCTAssertTrue(shareButton.waitForExistence(timeout: 10), "Share sheet did not dismiss on cancel")
}
private func openFirstPhoto(in photos: XCUIApplication) throws {
let photoGrid = photos.buttons["all_photos_grid"]
if photoGrid.waitForExistence(timeout: 5) {
// iPadOS 18 exposes the whole top thumbnail strip as one grid button.
photoGrid.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.9)).tap()
return
}
let firstPhoto = photos.scrollViews.images.firstMatch
guard firstPhoto.waitForExistence(timeout: 10) else {
throw XCTSkip("Photos library has no images; seed one with `xcrun simctl addmedia`.")
}
firstPhoto.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
private func tapShareButton(_ shareButton: XCUIElement, in photos: XCUIApplication) {
if shareButton.isHittable {
shareButton.tap()
return
}
let window = photos.windows.firstMatch
let windowFrame = window.frame
let buttonFrame = shareButton.frame
guard !windowFrame.isEmpty, !buttonFrame.isEmpty else {
XCTFail("Photos window or Share button has no frame")
return
}
let point = CGVector(
dx: buttonFrame.midX / windowFrame.width,
dy: buttonFrame.midY / windowFrame.height)
photos.coordinate(withNormalizedOffset: point).tap()
}
private func waitFor(_ element: XCUIElement, enabled: Bool, timeout: TimeInterval) -> Bool {
let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled))
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)