From 2f707f981152017bba642657f582b2bb49d5654e Mon Sep 17 00:00:00 2001 From: PollyBot13 Date: Thu, 2 Jul 2026 23:41:49 +0200 Subject: [PATCH] fix: prevent Voice Wake crash after Talk audio capture (#99137) * fix: prevent Voice Wake audio restart race * fix: handle in-flight Voice Wake suspension * test(ios): harden Voice Wake lifecycle proof * fix(ios): remove Voice Wake tap after failed start --------- Co-authored-by: Peter Steinberger --- apps/.i18n/native-source.json | 58 ++++++--- .../Design/SettingsProTabActions.swift | 3 + apps/ios/Sources/Voice/VoiceWakeManager.swift | 121 ++++++++++++++---- .../Tests/VoiceWakeManagerStateTests.swift | 26 ++-- .../VoiceWakeManagerSuppressionTests.swift | 72 +++++++++++ .../ios/UITests/OpenClawSnapshotUITests.swift | 66 +++++++++- 6 files changed, 290 insertions(+), 56 deletions(-) create mode 100644 apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 3afd09ceddbf..f7369035b398 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -8203,7 +8203,7 @@ }, { "kind": "conditional-branch", - "line": 610, + "line": 613, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -8211,7 +8211,7 @@ }, { "kind": "conditional-branch", - "line": 610, + "line": 613, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -8219,7 +8219,7 @@ }, { "kind": "conditional-branch", - "line": 675, + "line": 678, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -8227,7 +8227,7 @@ }, { "kind": "conditional-branch", - "line": 675, + "line": 678, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -8235,7 +8235,7 @@ }, { "kind": "conditional-branch", - "line": 730, + "line": 733, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -8243,7 +8243,7 @@ }, { "kind": "conditional-branch", - "line": 730, + "line": 733, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -8251,7 +8251,7 @@ }, { "kind": "conditional-branch", - "line": 735, + "line": 738, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -8259,7 +8259,7 @@ }, { "kind": "conditional-branch", - "line": 735, + "line": 738, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -8267,7 +8267,7 @@ }, { "kind": "conditional-branch", - "line": 737, + "line": 740, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -8275,7 +8275,7 @@ }, { "kind": "conditional-branch", - "line": 737, + "line": 740, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -8283,7 +8283,7 @@ }, { "kind": "conditional-branch", - "line": 762, + "line": 765, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", @@ -8291,7 +8291,7 @@ }, { "kind": "conditional-branch", - "line": 772, + "line": 775, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location \\(self.locationLabel)", "surface": "apple", @@ -8299,7 +8299,7 @@ }, { "kind": "conditional-branch", - "line": 772, + "line": 775, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Location off", "surface": "apple", @@ -8307,7 +8307,7 @@ }, { "kind": "conditional-branch", - "line": 777, + "line": 780, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Off", "surface": "apple", @@ -8315,7 +8315,7 @@ }, { "kind": "conditional-branch", - "line": 778, + "line": 781, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "While Using", "surface": "apple", @@ -8323,7 +8323,7 @@ }, { "kind": "conditional-branch", - "line": 779, + "line": 782, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Always", "surface": "apple", @@ -8331,7 +8331,7 @@ }, { "kind": "conditional-branch", - "line": 793, + "line": 796, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Checking iOS notification permission.", "surface": "apple", @@ -8339,7 +8339,7 @@ }, { "kind": "conditional-branch", - "line": 795, + "line": 798, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw can show approval prompts and event alerts when the app is not active.", "surface": "apple", @@ -8347,7 +8347,7 @@ }, { "kind": "conditional-branch", - "line": 797, + "line": 800, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Notifications have been denied. Enable them in iOS Settings.", "surface": "apple", @@ -8355,7 +8355,7 @@ }, { "kind": "conditional-branch", - "line": 799, + "line": 802, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Enable notifications to receive approval prompts and event alerts outside the app.", "surface": "apple", @@ -8363,7 +8363,7 @@ }, { "kind": "conditional-branch", - "line": 801, + "line": 804, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "OpenClaw cannot determine the current notification permission state.", "surface": "apple", @@ -11489,6 +11489,22 @@ "surface": "apple", "id": "native.apple.d2db26b3bb266491" }, + { + "kind": "conditional-branch", + "line": 260, + "path": "apps/ios/Sources/Voice/VoiceWakeManager.swift", + "source": "Off", + "surface": "apple", + "id": "native.apple.b6bc54876f20104f" + }, + { + "kind": "conditional-branch", + "line": 260, + "path": "apps/ios/Sources/Voice/VoiceWakeManager.swift", + "source": "Paused", + "surface": "apple", + "id": "native.apple.5590d661cdc81fbf" + }, { "kind": "ui-named-argument", "line": 96, diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 8c24fc1fc6ac..a210ad237552 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -80,6 +80,9 @@ extension SettingsProTab { detail: self.appModel.voiceWake.statusText, value: self.voiceWakeEnabled ? "on" : "off", color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("diagnostics-voice-wake-status") + .accessibilityValue(self.appModel.voiceWake.statusText) } } .padding(.horizontal, OpenClawProMetric.pagePadding) diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index 46174343bc82..cd5a572fb0c3 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -38,6 +38,17 @@ private final class AudioBufferQueue: @unchecked Sendable { } } +private enum VoiceWakeAudioError: LocalizedError { + case invalidInputFormat + + var errorDescription: String? { + switch self { + case .invalidInputFormat: + "Microphone input format unavailable" + } + } +} + extension AVAudioPCMBuffer { fileprivate func deepCopy() -> AVAudioPCMBuffer? { let format = self.format @@ -93,13 +104,25 @@ final class VoiceWakeManager: NSObject { private var recognitionTask: SFSpeechRecognitionTask? private var tapQueue: AudioBufferQueue? private var tapDrainTask: Task? + private var scheduledStartTask: Task? + private var isStarting: Bool = false + private var isSuspendedForExternalAudio: Bool = false private var lastDispatched: String? private var onCommand: (@Sendable (String) async -> Void)? private var userDefaultsObserver: NSObjectProtocol? private var suppressedByTalk: Bool = false - override init() { + private let externalAudioResumeDelayNs: UInt64 + private let recognitionErrorRestartDelayNs: UInt64 + + override convenience init() { + self.init(externalAudioResumeDelayNs: 350_000_000, recognitionErrorRestartDelayNs: 700_000_000) + } + + private init(externalAudioResumeDelayNs: UInt64, recognitionErrorRestartDelayNs: UInt64) { + self.externalAudioResumeDelayNs = externalAudioResumeDelayNs + self.recognitionErrorRestartDelayNs = recognitionErrorRestartDelayNs super.init() self.triggerWords = VoiceWakePreferences.loadTriggerWords() self.userDefaultsObserver = NotificationCenter.default.addObserver( @@ -137,7 +160,7 @@ final class VoiceWakeManager: NSObject { func setEnabled(_ enabled: Bool) { self.isEnabled = enabled if enabled { - Task { await self.start() } + self.scheduleStart() } else { self.stop() } @@ -146,20 +169,51 @@ final class VoiceWakeManager: NSObject { func setSuppressedByTalk(_ suppressed: Bool) { self.suppressedByTalk = suppressed if suppressed { - _ = self.suspendForExternalAudioCapture() + self.cancelScheduledStart() + if self.isListening { + self.isListening = false + self.tearDownRecognitionPipeline() + } if self.isEnabled { self.statusText = "Paused" } - } else { - if self.isEnabled { - Task { await self.start() } - } + } else if self.isEnabled { + self.scheduleStart() } } + private func scheduleStart(after delayNs: UInt64 = 0) { + guard self.isEnabled else { return } + + self.scheduledStartTask?.cancel() + self.scheduledStartTask = Task { [weak self] in + if delayNs > 0 { + try? await Task.sleep(nanoseconds: delayNs) + } + guard !Task.isCancelled else { return } + self?.scheduledStartTask = nil + await self?.start() + } + } + + private func cancelScheduledStart() { + self.scheduledStartTask?.cancel() + self.scheduledStartTask = nil + } + func start() async { guard self.isEnabled else { return } if self.isListening { return } + if self.isStarting { return } + guard !self.isSuspendedForExternalAudio else { + self.isListening = false + self.statusText = "Paused" + return + } + + self.isStarting = true + defer { self.isStarting = false } + guard !self.suppressedByTalk else { self.isListening = false self.statusText = "Paused" @@ -201,6 +255,12 @@ final class VoiceWakeManager: NSObject { return } + guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { + self.isListening = false + self.statusText = self.isEnabled ? "Paused" : "Off" + return + } + do { try Self.configureAudioSession() try self.startRecognition() @@ -208,6 +268,7 @@ final class VoiceWakeManager: NSObject { self.statusText = "Listening" } catch { self.isListening = false + self.tearDownRecognitionPipeline() self.statusText = "Start failed: \(error.localizedDescription)" } } @@ -216,14 +277,19 @@ final class VoiceWakeManager: NSObject { self.isEnabled = false self.isListening = false self.statusText = "Off" + self.isSuspendedForExternalAudio = false + self.cancelScheduledStart() self.tearDownRecognitionPipeline() } /// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio. - /// Returns `true` when listening was active and was suspended. + /// Returns `true` when listening, starting, or a pending restart was active and was suspended. func suspendForExternalAudioCapture() -> Bool { - guard self.isEnabled, self.isListening else { return false } + let hadPendingStart = self.scheduledStartTask != nil + self.cancelScheduledStart() + guard self.isEnabled, self.isListening || self.isStarting || hadPendingStart else { return false } + self.isSuspendedForExternalAudio = true self.isListening = false self.statusText = "Paused" self.tearDownRecognitionPipeline() @@ -232,10 +298,13 @@ final class VoiceWakeManager: NSObject { func resumeAfterExternalAudioCapture(wasSuspended: Bool) { guard wasSuspended else { return } - Task { await self.start() } + self.isSuspendedForExternalAudio = false + self.scheduleStart(after: self.externalAudioResumeDelayNs) } private func startRecognition() throws { + guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { return } + self.recognitionTask?.cancel() self.recognitionTask = nil self.tapDrainTask?.cancel() @@ -251,6 +320,9 @@ final class VoiceWakeManager: NSObject { inputNode.removeTap(onBus: 0) let recordingFormat = inputNode.outputFormat(forBus: 0) + guard recordingFormat.sampleRate > 0, recordingFormat.channelCount > 0 else { + throw VoiceWakeAudioError.invalidInputFormat + } let queue = AudioBufferQueue() self.tapQueue = queue @@ -292,8 +364,9 @@ final class VoiceWakeManager: NSObject { if self.audioEngine.isRunning { self.audioEngine.stop() - self.audioEngine.inputNode.removeTap(onBus: 0) } + // A tap can be installed before AVAudioEngine.start() throws. + self.audioEngine.inputNode.removeTap(onBus: 0) try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } @@ -317,13 +390,7 @@ final class VoiceWakeManager: NSObject { self.statusText = "Recognizer error: \(errorText)" self.isListening = false - let shouldRestart = self.isEnabled - if shouldRestart { - Task { - try? await Task.sleep(nanoseconds: 700_000_000) - await self.start() - } - } + self.scheduleStart(after: self.recognitionErrorRestartDelayNs) return } @@ -343,10 +410,7 @@ final class VoiceWakeManager: NSObject { } private func startIfEnabled() async { - let shouldRestart = self.isEnabled - if shouldRestart { - await self.start() - } + self.scheduleStart() } private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? { @@ -469,8 +533,21 @@ final class VoiceWakeManager: NSObject { #if DEBUG extension VoiceWakeManager { + static func _test_withoutRestartDelays() -> VoiceWakeManager { + VoiceWakeManager(externalAudioResumeDelayNs: 0, recognitionErrorRestartDelayNs: 0) + } + func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) { self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText) } + + func _test_setStartInFlight(_ isStarting: Bool) { + self.isStarting = isStarting + } + + func _test_waitForScheduledStart() async { + let task = self.scheduledStartTask + await task?.value + } } #endif diff --git a/apps/ios/Tests/VoiceWakeManagerStateTests.swift b/apps/ios/Tests/VoiceWakeManagerStateTests.swift index b117d8bb7770..a84900efa356 100644 --- a/apps/ios/Tests/VoiceWakeManagerStateTests.swift +++ b/apps/ios/Tests/VoiceWakeManagerStateTests.swift @@ -4,8 +4,8 @@ import Testing @testable import OpenClaw @Suite(.serialized) struct VoiceWakeManagerStateTests { - @Test @MainActor func suspendAndResumeCycleUpdatesState() async { - let manager = VoiceWakeManager() + @Test @MainActor func `suspend and resume cycle updates state`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() manager.isEnabled = true manager.isListening = true manager.statusText = "Listening" @@ -16,12 +16,12 @@ import Testing #expect(manager.statusText == "Paused") manager.resumeAfterExternalAudioCapture(wasSuspended: true) - try? await Task.sleep(nanoseconds: 900_000_000) - #expect(manager.statusText.contains("Voice Wake") == true) + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") } - @Test @MainActor func handleRecognitionCallbackRestartsOnError() async { - let manager = VoiceWakeManager() + @Test @MainActor func `handle recognition callback restarts on error`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() manager.isEnabled = true manager.isListening = true @@ -29,18 +29,20 @@ import Testing #expect(manager.statusText.contains("Recognizer error") == true) #expect(manager.isListening == false) - try? await Task.sleep(nanoseconds: 900_000_000) - #expect(manager.statusText.contains("Voice Wake") == true) + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") } - @Test @MainActor func handleRecognitionCallbackDispatchesCommand() async { + @Test @MainActor func `handle recognition callback dispatches command`() async throws { let manager = VoiceWakeManager() manager.triggerWords = ["openclaw"] manager.isEnabled = true actor CaptureBox { var value: String? - func set(_ next: String) { self.value = next } + func set(_ next: String) { + self.value = next + } } let capture = CaptureBox() manager.configure { cmd in @@ -48,8 +50,8 @@ import Testing } let transcript = "openclaw hello" - let triggerRange = transcript.range(of: "openclaw")! - let helloRange = transcript.range(of: "hello")! + let triggerRange = try #require(transcript.range(of: "openclaw")) + let helloRange = try #require(transcript.range(of: "hello")) let segments = [ WakeWordSegment(text: "openclaw", start: 0.0, duration: 0.2, range: triggerRange), WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange), diff --git a/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift b/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift new file mode 100644 index 000000000000..4aece2c82d82 --- /dev/null +++ b/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite("Voice Wake manager suppression", .serialized) +struct VoiceWakeManagerSuppressionTests { + @Test + @MainActor func `clearing Talk suppression restarts after pending start was canceled`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() + manager.isEnabled = true + manager.statusText = "Paused" + + manager.setSuppressedByTalk(true) + manager.setSuppressedByTalk(false) + + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") + #expect(manager.isListening == false) + } + + @Test + @MainActor func `external audio resumes pending Voice Wake restart`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() + manager.isEnabled = true + manager.resumeAfterExternalAudioCapture(wasSuspended: true) + + let suspended = manager.suspendForExternalAudioCapture() + #expect(suspended == true) + + manager.resumeAfterExternalAudioCapture(wasSuspended: suspended) + + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") + #expect(manager.isListening == false) + } + + @Test + @MainActor func `external audio resumes in flight Voice Wake start`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() + manager.isEnabled = true + manager._test_setStartInFlight(true) + + let suspended = manager.suspendForExternalAudioCapture() + #expect(suspended == true) + #expect(manager.statusText == "Paused") + + manager._test_setStartInFlight(false) + manager.resumeAfterExternalAudioCapture(wasSuspended: suspended) + + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") + #expect(manager.isListening == false) + } + + @Test + @MainActor func `Talk suppression toggle does not leave Voice Wake externally suspended`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() + manager.isEnabled = true + manager.isListening = true + + manager.setSuppressedByTalk(true) + let suspended = manager.suspendForExternalAudioCapture() + #expect(suspended == false) + + manager.setSuppressedByTalk(false) + manager.resumeAfterExternalAudioCapture(wasSuspended: suspended) + + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") + #expect(manager.isListening == false) + } +} diff --git a/apps/ios/UITests/OpenClawSnapshotUITests.swift b/apps/ios/UITests/OpenClawSnapshotUITests.swift index 838dcf511c49..840cfd84656a 100644 --- a/apps/ios/UITests/OpenClawSnapshotUITests.swift +++ b/apps/ios/UITests/OpenClawSnapshotUITests.swift @@ -92,6 +92,69 @@ final class OpenClawSnapshotUITests: XCTestCase { XCTAssertTrue(self.app?.tabBars.buttons["Talk"].isSelected == true) } + func testVoiceWakeResumesAfterTalkModeToggle() throws { + try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone Settings proof only") + self.addUIInterruptionMonitor(withDescription: "Microphone and speech permissions") { alert in + guard alert.buttons["Allow"].exists else { return false } + alert.buttons["Allow"].tap() + return true + } + self.launchApp(for: ScreenshotTarget( + initialTab: "settings", + initialDestination: "settings", + name: "voice-wake-talk-lifecycle")) + + let voiceSettings = try XCTUnwrap( + self.app?.buttons.containing(.staticText, identifier: "Voice & Talk").firstMatch) + XCTAssertTrue(voiceSettings.waitForExistence(timeout: 8)) + voiceSettings.tap() + + let voiceWake = try XCTUnwrap(self.app?.switches["Voice Wake"]) + let talkMode = try XCTUnwrap(self.app?.switches["Talk Mode"]) + XCTAssertTrue(voiceWake.waitForExistence(timeout: 5)) + XCTAssertTrue(talkMode.exists) + + if talkMode.value as? String == "1" { + talkMode.tap() + } + if voiceWake.value as? String == "1" { + voiceWake.tap() + } + + voiceWake.tap() + XCTAssertEqual(voiceWake.value as? String, "1") + talkMode.tap() + XCTAssertEqual(talkMode.value as? String, "1") + talkMode.tap() + XCTAssertEqual(talkMode.value as? String, "0") + XCTAssertEqual(voiceWake.value as? String, "1") + 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) + XCTAssertTrue(diagnostics.waitForExistence(timeout: 5)) + diagnostics.tap() + let voiceWakeStatus = try XCTUnwrap( + self.app?.descendants(matching: .any)["diagnostics-voice-wake-status"]) + XCTAssertTrue(voiceWakeStatus.waitForExistence(timeout: 5)) + let resumed = expectation( + for: NSPredicate( + format: "value == %@", + "Voice Wake isn’t supported on Simulator"), + evaluatedWith: voiceWakeStatus) + wait(for: [resumed], timeout: 5) + + let diagnosticsNavigationBar = try XCTUnwrap(self.app?.navigationBars["Diagnostics"]) + diagnosticsNavigationBar.buttons["BackButton"].tap() + voiceSettings.tap() + XCTAssertTrue(voiceWake.waitForExistence(timeout: 5)) + voiceWake.tap() + XCTAssertEqual(voiceWake.value as? String, "0") + } + func testChatComposerStartsCompactAndGrowsWithDraft() throws { try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone composer proof only") self.launchApp(for: ScreenshotTarget( @@ -126,7 +189,8 @@ final class OpenClawSnapshotUITests: XCTestCase { textField.tap() textField.typeText( - "Draft a polished launch note that covers the new design, validation, rollout plan, and follow-up details for the team.") + "Draft a polished launch note that covers the new design, validation, rollout plan, " + + "and follow-up details for the team.") let composerGrew = expectation( for: NSPredicate { _, _ in textField.frame.height >= compactHeight + 12 }, evaluatedWith: textField)