mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:17:14 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 isn’t 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)
|
||||
|
||||
@@ -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 ["Don’t 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)
|
||||
|
||||
Reference in New Issue
Block a user