diff --git a/apps/ios/Tests/AgentAutomationModelsTests.swift b/apps/ios/Tests/AgentAutomationModelsTests.swift index f2ffdec0b792..4cbe6ba1bdad 100644 --- a/apps/ios/Tests/AgentAutomationModelsTests.swift +++ b/apps/ios/Tests/AgentAutomationModelsTests.swift @@ -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 + } } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 630e4455bd89..5e3b79df9dd8 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -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.makeStream() diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 1c6be34b9db9..4e06996606f1 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -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)")) } diff --git a/apps/ios/Tests/OpenClawTypographyTests.swift b/apps/ios/Tests/OpenClawTypographyTests.swift index 75004a40313a..9f0355f212ec 100644 --- a/apps/ios/Tests/OpenClawTypographyTests.swift +++ b/apps/ios/Tests/OpenClawTypographyTests.swift @@ -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.. [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[.. Bool { - line.range(of: #"\b(Text|Label)\s*\("#, options: .regularExpression) != nil + self.textOrLabelCallPattern.firstMatch( + in: line, + range: NSRange(line.startIndex.. 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.. 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] { + 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] { + 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 } diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index a860354f3f84..819ab29efb37 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -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) diff --git a/apps/ios/UITests/ShareExtensionUITests.swift b/apps/ios/UITests/ShareExtensionUITests.swift index a743a4c40bc2..5c085ee84c5f 100644 --- a/apps/ios/UITests/ShareExtensionUITests.swift +++ b/apps/ios/UITests/ShareExtensionUITests.swift @@ -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)