From ebbbfa4c3016f80bd48a59364bc16edebd37e581 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:24:47 -0500 Subject: [PATCH] fix(ios): stabilize release screenshot launches --- apps/ios/Sources/OpenClawApp.swift | 4 +- apps/ios/Sources/RootTabs.swift | 18 ++++++ ...LiveActivityPresentationArbiterTests.swift | 1 + .../ios/UITests/OpenClawSnapshotUITests.swift | 60 ++++++++++++------- apps/ios/fastlane/Fastfile | 6 +- .../ios-release-fastlane-gates.test.ts | 3 + 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 831d3632bd25..8d5d59b62c3c 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -721,7 +721,9 @@ struct OpenClawApp: App { .environment(self.appModel.voiceWake) .environment(self.gatewayController) .task { - self.voiceLiveActivityCoordinator.start(appModel: self.appModel) + if !Self.screenshotModeEnabled { + self.voiceLiveActivityCoordinator.start(appModel: self.appModel) + } self.appModel.setScenePhase(self.scenePhase) self.appDelegate.appModel = self.appModel self.appDelegate.scenePhaseChanged(self.scenePhase) diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index f40e4b6ce2bb..73e3b795e5fb 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -135,6 +135,24 @@ struct RootTabs: View { self.rootOverlays( self.sidebarSplitContent .tint(OpenClawBrand.accent)))) + .overlay(alignment: .topLeading) { + self.uiTestReadinessMarker + } + } + + @ViewBuilder + private var uiTestReadinessMarker: some View { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--openclaw-ui-test-readiness") { + Color.clear + .frame(width: 1, height: 1) + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("RootTabs.Ready") + .accessibilityLabel(Text(verbatim: "OpenClaw test readiness")) + .accessibilityValue(self.scenePhase == .active ? "ready" : "inactive") + } + #endif } private var sidebarSplitContent: some View { diff --git a/apps/ios/Tests/LiveActivityPresentationArbiterTests.swift b/apps/ios/Tests/LiveActivityPresentationArbiterTests.swift index a7a6d9f9f291..f6ab8b211610 100644 --- a/apps/ios/Tests/LiveActivityPresentationArbiterTests.swift +++ b/apps/ios/Tests/LiveActivityPresentationArbiterTests.swift @@ -204,6 +204,7 @@ struct LiveActivityPresentationArbiterTests { "LiveActivity/VoiceLiveActivityCoordinator.swift"), encoding: .utf8) + #expect(appSource.contains("if !Self.screenshotModeEnabled {")) #expect(appSource.contains("self.voiceLiveActivityCoordinator.start(appModel: self.appModel)")) #expect(coordinatorSource.contains("LiveActivityManager.shared.showVoice(")) #expect(coordinatorSource.contains("LiveActivityManager.shared.updateVoiceLevel(")) diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index c3f8f00a1e92..1dbf62f1e1b7 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -15,7 +15,7 @@ final class OpenClawSnapshotUITests: XCTestCase { ScreenshotTarget(initialTab: "agent", initialDestination: "agents", name: "03-agent-connected"), ScreenshotTarget(initialTab: "settings", initialDestination: "settings", name: "04-settings-connected"), ] - private static let screenshotLaunchRetryThreshold: TimeInterval = 30 + private static let appReadinessAccessibilityIdentifier = "RootTabs.Ready" private var app: XCUIApplication? @@ -25,8 +25,7 @@ final class OpenClawSnapshotUITests: XCTestCase { } override func tearDownWithError() throws { - self.app?.terminate() - self.app = nil + self.terminateCurrentApp() try super.tearDownWithError() } @@ -723,6 +722,16 @@ final class OpenClawSnapshotUITests: XCTestCase { XCTAssertTrue(self.app?.buttons["Online"].exists == true) XCTAssertTrue(self.app?.buttons["Ready"].exists == true) self.attachScreenshot(named: "agent-toolbar-filter") + + // Native context menus must finish their dismissal before teardown. Killing + // the app with this menu open can leave the next app scene inactive. + self.app?.buttons["Ready"].tap() + self.waitForValue("Ready", of: menu) + menu.tap() + let all = try XCTUnwrap(self.app?.buttons["All"]) + XCTAssertTrue(all.waitForExistence(timeout: 3)) + all.tap() + self.waitForValue("All", of: menu) } func testLiveGatewayFreshInstallSetupAndRelaunch() throws { @@ -908,7 +917,7 @@ final class OpenClawSnapshotUITests: XCTestCase { screenshotMode: Bool = true, additionalArguments: [String] = []) { - self.app?.terminate() + self.terminateCurrentApp() let app = XCUIApplication() setupSnapshot(app) @@ -919,6 +928,7 @@ final class OpenClawSnapshotUITests: XCTestCase { target.initialDestination, "--openclaw-sidebar-visibility", "hidden", + "--openclaw-ui-test-readiness", ] if screenshotMode { app.launchArguments.append("--openclaw-screenshot-mode") @@ -927,31 +937,39 @@ final class OpenClawSnapshotUITests: XCTestCase { if let appearance { app.launchArguments += ["--openclaw-appearance", appearance] } - let launchStartedAt = Date() app.launch() self.app = app - if screenshotMode, - Date().timeIntervalSince(launchStartedAt) >= Self.screenshotLaunchRetryThreshold - { - // Xcode can return after its idle timeout while the screenshot scene is still - // inactive; one fresh launch avoids querying a stalled accessibility tree. - app.terminate() - let retryStartedAt = Date() - app.launch() - XCTAssertLessThan( - Date().timeIntervalSince(retryStartedAt), - Self.screenshotLaunchRetryThreshold, - "Screenshot app launch stalled again after one retry") - } - XCTAssertTrue(app.wait(for: .runningForeground, timeout: 8)) + let readiness = app.descendants(matching: .any)[Self.appReadinessAccessibilityIdentifier] + XCTAssertTrue( + readiness.waitForExistence(timeout: 8), + "OpenClaw root readiness marker did not appear") + self.waitForValue("ready", of: readiness, timeout: 8) } - private func waitForValue(_ value: String, of element: XCUIElement) { + private func terminateCurrentApp( + file: StaticString = #filePath, + line: UInt = #line) + { + guard let app = self.app else { return } + app.terminate() + XCTAssertTrue( + app.wait(for: .notRunning, timeout: 5), + "OpenClaw did not terminate before the next launch", + file: file, + line: line) + self.app = nil + } + + private func waitForValue( + _ value: String, + of element: XCUIElement, + timeout: TimeInterval = 3) + { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate(format: "value == %@", value), object: element) - XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed) } private func waitForEnabled(_ element: XCUIElement) { diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index a07976860ba9..734288cf4183 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -1680,9 +1680,9 @@ platform :ios do configuration: "Debug", devices: [device], languages: ["en-US"], - # The scheme also carries functional UI tests (share extension); the - # screenshot lane must only drive the deterministic snapshot suite. - only_testing: ["OpenClawUITests/OpenClawSnapshotUITests"], + # App Store capture is isolated from functional UI checks; this lane only + # needs the deterministic four-screenshot release flow. + only_testing: ["OpenClawUITests/OpenClawSnapshotUITests/testConnectedGatewayTabs"], launch_arguments: ["--openclaw-screenshot-mode"], output_directory: output_directory, clear_previous_screenshots: index.zero?, diff --git a/test/scripts/ios-release-fastlane-gates.test.ts b/test/scripts/ios-release-fastlane-gates.test.ts index 7c86102e92e4..4db0f475d56b 100644 --- a/test/scripts/ios-release-fastlane-gates.test.ts +++ b/test/scripts/ios-release-fastlane-gates.test.ts @@ -118,6 +118,9 @@ describe("iOS Fastlane release upload gates", () => { const verifier = functionBody(fastfile, "verify_snapshot_test_result!"); expect(screenshots).toContain("snapshot_devices.each_with_index"); + expect(screenshots).toContain( + 'only_testing: ["OpenClawUITests/OpenClawSnapshotUITests/testConnectedGatewayTabs"]', + ); expect(screenshots).toContain("result_bundle: true"); expect(screenshots).toContain("number_of_retries: 0"); expect(screenshots).toContain("stop_after_first_error: true");