From b898c3382cdd9a874b641c3eefdd54de312df6eb Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Wed, 30 Sep 2026 18:42:32 -0400 Subject: [PATCH] ios: stop re-rendering the conversation on every keystroke The composer draft was a property on ConversationScreenModel, bound with $bindableScreenModel.composerInputText inside ConversationDestinationScreen's body. Every keystroke re-ran that body, rebuilt ConversationView with new closures, and re-rendered the transcript. Measured over 104 keystrokes after a finished Claude turn in the simulator: ConversationView.body 81 -> 1 MessageList.body 268 -> 4 BottomChrome.body 81 -> 1 pinned-diff rebuild 81 -> 1 The cost grows with the transcript, which is why typing froze once a turn had added tool output and markdown. - Move the draft text and attachments into ConversationComposerDraft, an observable passed down by reference. Only ComposerDraftBinder, a leaf in the bottom chrome, builds the bindings, so keystrokes invalidate the composer alone. - ConversationPinnedContextStrip built the combined diff in init on every parent re-render although SwiftUI keeps only the first @State initial value; build it in onChange(initial: true) instead. - Add live UI tests against a kittylitter host (skipped unless env is set): typing after a turn settles, a screenshot tour, and open-session timing. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/ios/Litter.xcodeproj/project.pbxproj | 8 + apps/ios/Sources/Litter/LitterApp.swift | 8 +- .../Views/ConversationScreenModel.swift | 27 +- .../Views/ConversationTimelineView.swift | 8 +- .../Litter/Views/ConversationView.swift | 46 ++-- .../Tests/LitterUITests/LiveTourUITests.swift | 237 ++++++++++++++++++ .../LiveTypingAfterTurnUITests.swift | 168 +++++++++++++ 7 files changed, 465 insertions(+), 37 deletions(-) create mode 100644 apps/ios/Tests/LitterUITests/LiveTourUITests.swift create mode 100644 apps/ios/Tests/LitterUITests/LiveTypingAfterTurnUITests.swift diff --git a/apps/ios/Litter.xcodeproj/project.pbxproj b/apps/ios/Litter.xcodeproj/project.pbxproj index 3a8269550..623d7bd52 100644 --- a/apps/ios/Litter.xcodeproj/project.pbxproj +++ b/apps/ios/Litter.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ 25A6AE0E882A1230E8113D76 /* StableSafeAreaInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = C701571F55C11FBF7C1C29F3 /* StableSafeAreaInsets.swift */; }; 25ACF260028E1293314A6AC3 /* LitterComplicationEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4578B3ADA4BFFC01A6910404 /* LitterComplicationEntry.swift */; }; 25D96A70E576C0590C1B7C7A /* ApprovalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDA77FA2F0C9DF92D098BDA /* ApprovalScreen.swift */; }; + 26123F9592B0A9878A9A4323 /* LiveTourUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1622B326A565CB0A52D1AF73 /* LiveTourUITests.swift */; }; 2691245C74CDCB1714EADEC7 /* notion-light.json in Resources */ = {isa = PBXBuildFile; fileRef = 99BFC7A01A194E7508C5693C /* notion-light.json */; }; 27B1C10E386286FA2A30A8DF /* MinigameOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22B0C284B20FB135A3A35BE /* MinigameOverlayView.swift */; }; 2825608DFF86022012E9CAAE /* SavedServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F4C10A9AB37FCCDDA975381 /* SavedServer.swift */; }; @@ -442,6 +443,7 @@ A83CFAE1805823A320144A89 /* LitterServerListPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9ECBFB0BFC09594F6E0814F /* LitterServerListPayload.swift */; }; A91B73BD99881AF4EF7388BE /* RustAlleycatBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CDBDD02E344E7C903D5DEC /* RustAlleycatBridge.swift */; }; AA10EE43A455D870C2B01F65 /* DynamicTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4874A6712A57F780064DB0E /* DynamicTools.swift */; }; + AA3132226FC9145F389A5960 /* LiveTypingAfterTurnUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85D14738F56EC016845C6921 /* LiveTypingAfterTurnUITests.swift */; }; AA31352E315C5C623403E4DA /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE71A015A197F8B7F6AE82B8 /* Security.framework */; }; AAB89F368EA6954B70DE0D09 /* MessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2325BAE1FF0D41A4D5ADE3AD /* MessageRecorder.swift */; }; AABDEAC2D1616649533BC57E /* VideoWallpaperPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B361A4FDCFF12B0FE7E893EC /* VideoWallpaperPlayerView.swift */; }; @@ -768,6 +770,7 @@ 13BD448AC1B95ECDF642BF20 /* usgc-metalgate.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "usgc-metalgate.json"; sourceTree = ""; }; 14B7D7A9258301037FB7DBC0 /* night-owl.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "night-owl.json"; sourceTree = ""; }; 159DE69EDAAB27536744024C /* cat_transmission_06.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = cat_transmission_06.png; sourceTree = ""; }; + 1622B326A565CB0A52D1AF73 /* LiveTourUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTourUITests.swift; sourceTree = ""; }; 1641427B3606638F140B4E9A /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 169A7268F57D1E75A523B40F /* usgc-epitaxy.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "usgc-epitaxy.json"; sourceTree = ""; }; 17099E066405571104CB9A37 /* SessionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsScreen.swift; sourceTree = ""; }; @@ -946,6 +949,7 @@ 835AE4DDAB1DE3E7AEB3EA7F /* ComposerPermissionChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerPermissionChip.swift; sourceTree = ""; }; 83D1805F2EFF51A7DD62CB88 /* github-dark-default.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "github-dark-default.json"; sourceTree = ""; }; 85743F1AC88293BE8BBD27DA /* ImageGenerationToolCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGenerationToolCallView.swift; sourceTree = ""; }; + 85D14738F56EC016845C6921 /* LiveTypingAfterTurnUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTypingAfterTurnUITests.swift; sourceTree = ""; }; 86A94747FCE745BAA2DBEBF4 /* CrossServerToolResultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossServerToolResultView.swift; sourceTree = ""; }; 87E1D2E7E6CB4DA678C6605E /* github-dark.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "github-dark.json"; sourceTree = ""; }; 885259DDCC6E448B237FAA61 /* temple-dark.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "temple-dark.json"; sourceTree = ""; }; @@ -1177,6 +1181,8 @@ children = ( 46D841AF0FFB0241834F88D0 /* LitterUITests.swift */, 2D77BFF764026CE68A0C394F /* LiveE2EUITests.swift */, + 1622B326A565CB0A52D1AF73 /* LiveTourUITests.swift */, + 85D14738F56EC016845C6921 /* LiveTypingAfterTurnUITests.swift */, E724333261AD4653114210E9 /* MessageLinkUITests.swift */, ); name = LitterUITests; @@ -2654,6 +2660,8 @@ files = ( 7F7580E318B61556359A4EC1 /* LitterUITests.swift in Sources */, 23E8FBDBD957414ADADDAB18 /* LiveE2EUITests.swift in Sources */, + 26123F9592B0A9878A9A4323 /* LiveTourUITests.swift in Sources */, + AA3132226FC9145F389A5960 /* LiveTypingAfterTurnUITests.swift in Sources */, E21627B0742DBE674DC230C4 /* MessageLinkUITests.swift in Sources */, 05A1BF5FB8220D9D48C611D0 /* SnapshotHelper.swift in Sources */, ); diff --git a/apps/ios/Sources/Litter/LitterApp.swift b/apps/ios/Sources/Litter/LitterApp.swift index 664310efb..3051f2fc7 100644 --- a/apps/ios/Sources/Litter/LitterApp.swift +++ b/apps/ios/Sources/Litter/LitterApp.swift @@ -1978,7 +1978,6 @@ private struct ConversationDestinationScreen: View { var body: some View { Group { if let conversationThread { - @Bindable var bindableScreenModel = screenModel ConversationView( thread: conversationThread, activeThreadKey: resolvedThreadKey, @@ -1989,8 +1988,7 @@ private struct ConversationDestinationScreen: View { resolveTargetLabel: screenModel.resolveTargetLabel, resolveThreadKey: screenModel.resolveThreadKey, resolveLiveStatus: screenModel.resolveLiveStatus, - composerInputText: $bindableScreenModel.composerInputText, - composerAttachedImages: $bindableScreenModel.composerAttachedImages, + composerDraft: screenModel.composerDraft, topInset: 4, bottomInset: bottomInset, onOpenConversation: onOpenConversation, @@ -2108,7 +2106,6 @@ private struct ReplayDestinationScreen: View { var body: some View { Group { if let thread = conversationThread, let key = replayThreadKey { - @Bindable var bindableScreenModel = screenModel ConversationView( thread: thread, activeThreadKey: key, @@ -2119,8 +2116,7 @@ private struct ReplayDestinationScreen: View { resolveTargetLabel: screenModel.resolveTargetLabel, resolveThreadKey: screenModel.resolveThreadKey, resolveLiveStatus: screenModel.resolveLiveStatus, - composerInputText: $bindableScreenModel.composerInputText, - composerAttachedImages: $bindableScreenModel.composerAttachedImages, + composerDraft: screenModel.composerDraft, topInset: 0, bottomInset: bottomInset, onOpenConversation: nil, diff --git a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift index f7689ee32..6ed1a469d 100644 --- a/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift +++ b/apps/ios/Sources/Litter/Views/ConversationScreenModel.swift @@ -106,13 +106,14 @@ final class ConversationScreenModel { /// ConversationToolbarControls via param instead of /// `appModel.snapshot` in body (which would create a per-token edge). private(set) var serverSnapshot: AppServerSnapshot? - /// Live composer draft. Lifted out of `ConversationInputBar` so it - /// survives view teardown when `ConversationDestinationScreen` flips - /// through its `if let conversationThread` branch during foreground - /// refresh — otherwise typed-but-unsent text and pasted attachments - /// vanish on app switch. - var composerInputText: String = "" - var composerAttachedImages: [UIImage] = [] + /// Live composer draft. Owned here so it survives view teardown when + /// `ConversationDestinationScreen` flips through its + /// `if let conversationThread` branch during foreground refresh — + /// otherwise typed-but-unsent text and pasted attachments vanish on app + /// switch. A separate object, passed down by reference, so only the + /// composer observes keystrokes: a binding to a property here made every + /// keystroke re-render the whole conversation screen and transcript. + let composerDraft = ConversationComposerDraft() /// Precomputed closure that resolves agent target labels from a captured /// snapshot of `sessionSummaries`. Reading `appModel.snapshot` inside a @@ -165,8 +166,8 @@ final class ConversationScreenModel { minigameTask?.cancel() minigameTask = nil minigameOverlay = .idle - composerInputText = "" - composerAttachedImages = [] + composerDraft.text = "" + composerDraft.attachedImages = [] } refreshState() @@ -573,3 +574,11 @@ extension ConversationScreenModel { } } #endif + +/// The conversation composer's unsent text and attachments. +@MainActor +@Observable +final class ConversationComposerDraft { + var text: String = "" + var attachedImages: [UIImage] = [] +} diff --git a/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift b/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift index 543a164ce..947fa1772 100644 --- a/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationTimelineView.swift @@ -1989,9 +1989,6 @@ struct ConversationPinnedContextStrip: View { init(items: [ConversationItem]) { self.items = items - _cachedCombinedPinnedDiff = State( - initialValue: Self.buildCombinedPinnedDiff(from: items) - ) } var body: some View { @@ -2023,7 +2020,10 @@ struct ConversationPinnedContextStrip: View { sections: presentedDiff.sections ) } - .onChange(of: pinnedDiffTaskKey, initial: false) { _, _ in + // Built only when the pinned items change. Building it in `init` + // ran on every parent re-render, although SwiftUI keeps only the + // first `@State` initial value. + .onChange(of: pinnedDiffTaskKey, initial: true) { _, _ in cachedCombinedPinnedDiff = Self.buildCombinedPinnedDiff(from: items) } } diff --git a/apps/ios/Sources/Litter/Views/ConversationView.swift b/apps/ios/Sources/Litter/Views/ConversationView.swift index e7bb2951d..07dc25ce8 100644 --- a/apps/ios/Sources/Litter/Views/ConversationView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationView.swift @@ -21,8 +21,7 @@ struct ConversationView: View { var resolveTargetLabel: (String) -> String? var resolveThreadKey: (String) -> ThreadKey? var resolveLiveStatus: (ThreadKey) -> AppSubagentStatus? - @Binding var composerInputText: String - @Binding var composerAttachedImages: [UIImage] + let composerDraft: ConversationComposerDraft var topInset: CGFloat = 0 var bottomInset: CGFloat = 0 var onOpenConversation: ((ThreadKey) -> Void)? = nil @@ -146,8 +145,7 @@ struct ConversationView: View { ConversationBottomChrome( pinnedContextItems: pinnedContextItems, composer: composer, - composerInputText: $composerInputText, - composerAttachedImages: $composerAttachedImages, + composerDraft: composerDraft, onSend: sendMessage, onFileSearch: searchComposerFiles, bottomInset: bottomInset, @@ -412,12 +410,22 @@ private extension AppThreadSnapshot { } } +/// Builds the draft bindings inside a leaf so keystrokes invalidate only the +/// composer, not the conversation screen that holds the draft. +private struct ComposerDraftBinder: View { + @Bindable var draft: ConversationComposerDraft + @ViewBuilder let content: (Binding, Binding<[UIImage]>) -> Content + + var body: some View { + content($draft.text, $draft.attachedImages) + } +} + private struct ConversationBottomChrome: View { @Environment(AppModel.self) private var appModel let pinnedContextItems: [ConversationItem] let composer: ConversationComposerSnapshot - @Binding var composerInputText: String - @Binding var composerAttachedImages: [UIImage] + let composerDraft: ConversationComposerDraft let onSend: (String, [UIImage], [ComposerFileAttachment], [SkillMentionSelection], [PluginMentionSelection]) -> Void let onFileSearch: (String) async throws -> [FileSearchResult] var bottomInset: CGFloat = 0 @@ -433,18 +441,20 @@ private struct ConversationBottomChrome: View { ConversationPinnedContextStrip( items: pinnedContextItems ) - ConversationInputBar( - snapshot: composer, - onSend: onSend, - onFileSearch: onFileSearch, - bottomInset: bottomInset, - showModeChip: !hasPinnedDiff, - onOpenModePicker: openCollaborationModePicker, - onOpenConversation: onOpenConversation, - onResumeSessions: onResumeSessions, - inputText: $composerInputText, - attachedImages: $composerAttachedImages - ) + ComposerDraftBinder(draft: composerDraft) { text, images in + ConversationInputBar( + snapshot: composer, + onSend: onSend, + onFileSearch: onFileSearch, + bottomInset: bottomInset, + showModeChip: !hasPinnedDiff, + onOpenModePicker: openCollaborationModePicker, + onOpenConversation: onOpenConversation, + onResumeSessions: onResumeSessions, + inputText: text, + attachedImages: images + ) + } .background(.clear, ignoresSafeAreaEdges: .bottom) } // Line the composer up with the transcript column on wide surfaces. diff --git a/apps/ios/Tests/LitterUITests/LiveTourUITests.swift b/apps/ios/Tests/LitterUITests/LiveTourUITests.swift new file mode 100644 index 000000000..83299b91c --- /dev/null +++ b/apps/ios/Tests/LitterUITests/LiveTourUITests.swift @@ -0,0 +1,237 @@ +import XCTest + +/// Screenshot tour of the real app against a paired kittylitter host, for +/// design and history audits. Skipped unless `LITTER_E2E_TOUR` is set (pass +/// it with the `TEST_RUNNER_` prefix). Screenshots are kept as attachments. +final class LiveTourUITests: XCTestCase { + @MainActor + func testTour() throws { + guard ProcessInfo.processInfo.environment["LITTER_E2E_TOUR"] != nil else { + throw XCTSkip("LITTER_E2E_TOUR not set") + } + continueAfterFailure = true + let app = XCUIApplication() + addUIInterruptionMonitor(withDescription: "system alerts") { alert in + for label in ["Don’t Allow", "Don't Allow", "Allow Paste"] { + if alert.buttons[label].exists { alert.buttons[label].tap(); return true } + } + return false + } + app.launch() + XCTAssertTrue(app.buttons["home.settingsButton"].waitForExistence(timeout: 20)) + sleep(4) + dismissWebSheet(app) + shot(app, "01-home") + + let picker = app.descendants(matching: .any)["home.computerPicker"].firstMatch + if picker.waitForExistence(timeout: 5) { + picker.tap() + sleep(1) + shot(app, "02-computer-picker") + let mac = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'macbook'")).firstMatch + if mac.waitForExistence(timeout: 3) { + mac.tap() + } else { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() + if let path = ProcessInfo.processInfo.environment["LITTER_E2E_PAIR_JSON_FILE"], + let json = try? String(contentsOfFile: path, encoding: .utf8) { + pair(app, json: json) + sleep(8) + picker.tap() + sleep(1) + shot(app, "02b-picker-after-pair") + if mac.waitForExistence(timeout: 20) { mac.tap() } else { app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9)).tap() } + } + } + sleep(2) + dismissWebSheet(app) + shot(app, "03-home-mac") + } + + let model = app.descendants(matching: .any)["conversation.modelPickerButton"].firstMatch + if model.waitForExistence(timeout: 5) { + model.tap() + sleep(2) + shot(app, "04-model-picker") + app.swipeUp() + sleep(1) + shot(app, "05-model-picker-scrolled") + app.terminate() + app.launch() + XCTAssertTrue(app.buttons["home.settingsButton"].waitForExistence(timeout: 20)) + sleep(3) + dismissWebSheet(app) + } + + let allSessions = app.buttons["home.allSessionsButton"] + if allSessions.waitForExistence(timeout: 10) { allSessions.tap() } + let rows = app.descendants(matching: .any).matching(identifier: "sessions.sessionRow") + _ = rows.firstMatch.waitForExistence(timeout: 20) + sleep(2) + shot(app, "06-sessions") + let count = min(rows.count, 4) + for index in 0.. `. +final class LiveTypingAfterTurnUITests: XCTestCase { + private let draft = String(repeating: "the quick brown fox jumps ", count: 4) + + @MainActor + func testTypingStaysResponsiveAfterTurnSettles() throws { + let env = ProcessInfo.processInfo.environment + guard let path = env["LITTER_E2E_PAIR_JSON_FILE"], + let json = try? String(contentsOfFile: path, encoding: .utf8) else { + throw XCTSkip("LITTER_E2E_PAIR_JSON_FILE not set") + } + let app = XCUIApplication() + addUIInterruptionMonitor(withDescription: "system alerts") { alert in + for label in ["Don’t Allow", "Don't Allow", "Allow Paste", "Paste JSON Instead"] { + if alert.buttons[label].exists { alert.buttons[label].tap(); return true } + } + return false + } + app.launch() + XCTAssertTrue(app.buttons["home.settingsButton"].waitForExistence(timeout: 20)) + sleep(3) + // The phone's own runtime can open an OpenAI sign-in web sheet. + for label in ["Close", "Cancel"] where app.buttons[label].firstMatch.exists { + app.buttons[label].firstMatch.tap() + sleep(1) + } + + // The phone's own runtime is also a "computer"; pick the paired Mac. + let picker = app.descendants(matching: .any)["home.computerPicker"].firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 20), "computer picker missing") + if !selectMac(app, picker: picker) { + pair(app, json: json) + XCTAssertTrue(picker.waitForExistence(timeout: 20)) + let deadline = Date().addingTimeInterval(45) + var selected = false + while !selected && Date() < deadline { + selected = selectMac(app, picker: picker) + if !selected { sleep(3) } + } + XCTAssertTrue(selected, "paired Mac never became selectable") + } + + // Baseline: the same draft on the home composer, before any turn. + let homeComposer = app.textViews.firstMatch + XCTAssertTrue(homeComposer.waitForExistence(timeout: 10)) + homeComposer.tap() + var t = Date() + homeComposer.typeText(draft) + timing("type_home_baseline", since: t) + clear(homeComposer) + + homeComposer.typeText( + "Write a markdown answer with a heading, three short paragraphs, " + + "a bullet list of eight items, and a 20-line Swift code block. " + + "Do not run any tools." + ) + let send = app.buttons["conversation.sendButton"].firstMatch + expectation(for: NSPredicate(format: "isEnabled == true"), evaluatedWith: send) + waitForExpectations(timeout: 10) + send.tap() + + let stop = app.buttons["conversation.cancel-responseButton"] + XCTAssertTrue(stop.waitForExistence(timeout: 60), "turn never started") + t = Date() + let settled = NSPredicate(format: "exists == false") + expectation(for: settled, evaluatedWith: stop) + waitForExpectations(timeout: 240) + timing("turn_duration", since: t) + sleep(3) + dismissSpringboardAlerts() + + let composer = app.textViews.firstMatch + XCTAssertTrue(composer.waitForExistence(timeout: 10)) + composer.tap() + print("E2E_MARK typing_start \(Date().timeIntervalSince1970)") + t = Date() + composer.typeText(draft) + timing("type_after_settle", since: t) + t = Date() + composer.typeText(draft) + timing("type_after_settle_2", since: t) + print("E2E_MARK typing_end \(Date().timeIntervalSince1970)") + } + + /// Opens the computer picker and selects the paired Mac. Returns false + /// (with the menu dismissed) when no Mac is listed. + private func selectMac(_ app: XCUIApplication, picker: XCUIElement) -> Bool { + if (picker.label as NSString).range(of: "macbook", options: .caseInsensitive).location != NSNotFound { + return true + } + picker.tap() + let mac = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'macbook'")).firstMatch + if mac.waitForExistence(timeout: 3) { + mac.tap() + sleep(1) + return true + } + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)).tap() + return false + } + + /// The notification permission prompt appears after the first turn. + private func dismissSpringboardAlerts() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for label in ["Don’t Allow", "Don't Allow"] where springboard.buttons[label].exists { + springboard.buttons[label].tap() + sleep(1) + } + } + + private func clear(_ element: XCUIElement) { + guard let value = element.value as? String, !value.isEmpty else { return } + element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count)) + } + + private func pair(_ app: XCUIApplication, json: String) { + app.buttons["home.settingsButton"].tap() + let computers = app.buttons["settings.category.computers"] + XCTAssertTrue(computers.waitForExistence(timeout: 5)) + computers.tap() + let add = app.buttons["settings.addComputer"] + XCTAssertTrue(add.waitForExistence(timeout: 5)) + add.tap() + app.buttons["discovery.chooser.kittylitter"].tap() + sleep(2) + app.tap() // trigger the interruption monitor for the camera prompt + if app.alerts.buttons["Paste JSON Instead"].waitForExistence(timeout: 3) { + app.alerts.buttons["Paste JSON Instead"].tap() + } + // The QR scanner opens as a full-screen cover over the paste button. + let cancelScanner = app.buttons["Cancel"].firstMatch + if cancelScanner.waitForExistence(timeout: 3) { cancelScanner.tap(); sleep(1) } + let paste = app.buttons["Paste Pairing JSON"] + XCTAssertTrue(paste.waitForExistence(timeout: 10)) + paste.tap() + let field = app.textViews["alleycat.pair.jsonField"] + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + field.typeText(json.trimmingCharacters(in: .whitespacesAndNewlines)) + allowPasteIfPrompted() + app.buttons["Parse JSON"].tap() + allowPasteIfPrompted() + let connect = app.buttons["alleycat.pair.toolbarConnect"] + expectation(for: NSPredicate(format: "isEnabled == true"), evaluatedWith: connect) + waitForExpectations(timeout: 30) + connect.tap() + let row = app.descendants(matching: .any)["settings.computerRow"].firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 30), "computer not listed in Settings") + app.navigationBars["Computers"].buttons.element(boundBy: 0).tap() + let done = app.buttons["settings.done"] + XCTAssertTrue(done.waitForExistence(timeout: 5)) + done.tap() + } + + private func allowPasteIfPrompted() { + let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons["Allow Paste"] + if allow.waitForExistence(timeout: 2) { allow.tap() } + } + + private func timing(_ step: String, since: Date) { + print(String(format: "E2E_TIMING %@ %.2f", step, Date().timeIntervalSince(since))) + } +}