From ec986a5ab2e34858f33fef58b73af55f27742224 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 08:42:50 -0400 Subject: [PATCH 1/2] fix(chat): empty the composer reliably after a send Sending sometimes left the sent text in the field, so the next message had to be erased before it could be written. The send-button spring was on the whole composer row rather than on the button. Tapping Send flips `showsSubmit` true to false on the same update that sets `draft` to empty, so the field's text update ran as an animated one against its backing text view, where it can be coalesced away. The binding reads empty either way, and an unchanged binding never pushes again, so a missed update is permanent. Whether it is missed depends on the field's pending-edit state at the tap, which is why it only happened sometimes. Scoping the spring to the button keeps the field out of that transaction. The button still pops in and out: the `.animation(_:value:)` now sits on the `Group` wrapping the conditional, which is what supplies its transition's transaction. `ComposerModel` is untouched. `clear()` already emptied the draft synchronously before the send was dispatched, and its tests already covered that. --- .../Conversation/ConversationBottomBar.swift | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 795140fa5..34e84172e 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -279,26 +279,33 @@ struct ConversationComposer: View { // text-field automation type, so the query has to be identifier-based, not type-based. .accessibilityIdentifier("composer-message-field") - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + Group { + if showsSubmit { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: 34, height: 34) + .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) } + .animation(Self.sendButtonSpring, value: showsSubmit) } - .animation(Self.sendButtonSpring, value: showsSubmit) return field .padding(.leading, 14) From eaba73009dff9fb4fe73a22cec38c166131d9386 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 9 Sep 2026 08:59:06 -0400 Subject: [PATCH 2/2] test(chat): check the composer is empty after every send Three UI tests around the send path: a run of plain sends, a reply, and a send made while the field is mid-edit. Each reads the field back after the send and fails if the sent text is still there. The assertion reports which side of the binding is stale. The send button is drawn from the same emptiness the field is, so a button that has gone away while the text remains means the draft emptied and the text view never caught up, rather than the send never firing. Sends are dispatched without waiting for the button to settle, and delivery is not awaited between them: the gap between the last keystroke and the tap is the window the failure was thought to live in, and waiting anywhere in it settles it. The tests pass against the pre-fix ConversationBottomBar, so they do not reproduce the reported failure and do not stand as evidence for the fix in ec986a5a. A control that types without sending does fail, so the assertion reads the field rather than passing vacuously. --- .../Smoke/ComposerClearSmokeTests.swift | 110 ++++++++++++++++++ .../Screens/ConversationUIScreen.swift | 39 +++++++ 2 files changed, 149 insertions(+) create mode 100644 FlipcashUITests/Smoke/ComposerClearSmokeTests.swift diff --git a/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift b/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift new file mode 100644 index 000000000..514462070 --- /dev/null +++ b/FlipcashUITests/Smoke/ComposerClearSmokeTests.swift @@ -0,0 +1,110 @@ +// +// ComposerClearSmokeTests.swift +// FlipcashUITests +// + +import XCTest + +/// Sending has to leave the composer empty. It sometimes did not: the sent text stayed in the +/// field, so the next message had to be erased before one could be written. +/// +/// The failure is a race between the last keystroke and the send, not a property of any one +/// message, so a single send proves nothing either way. Each test sends a run of them and checks +/// the field after every one. +/// +/// **Prerequisites:** the `FLIPCASH_UI_TEST_ACCESS_KEY` account needs at least one chat +/// conversation. With none, each test skips rather than fails. +@MainActor +final class ComposerClearSmokeTests: BaseUITestCase { + + override var requiresAuthentication: Bool { true } + + /// Sends per test. Enough runs at the race to make a real failure likely, few enough that the + /// suite stays inside its time allowance — the sends are real, and each one reaches the server. + private static let sendCount = 6 + + private var conversation: ConversationUIScreen { ConversationUIScreen(app: app) } + + override func setUp() async throws { + try await super.setUp() + // A run of round-trip sends passes XCTest's 2-minute default. + executionTimeAllowance = 600 + } + + /// The plain case: type, send, and the field is empty and ready for the next message. + /// + /// Delivery is deliberately not awaited between sends. What is under test is the composer's + /// state the instant the send is dispatched, and waiting on a receipt would settle the very + /// window the failure lives in. + func testSendingMessages_leavesTheComposerEmptyEachTime() throws { + try openConversation() + + for attempt in 1...Self.sendCount { + let message = Self.uniqueText("clear \(attempt)") + conversation.sendWithoutSettling(message, from: self) + conversation.assertComposerCleared(of: message) + } + } + + /// The reply branch of the send, which empties the field through the same call while also + /// taking the composer out of replying — a second state change landing on the same update. + func testSendingAReply_leavesTheComposerEmpty() throws { + try openConversation() + + let original = Self.uniqueText("original") + conversation.sendMessage(original, from: self) + conversation.assertMessageDelivered(original) + + let answer = Self.uniqueText("answer") + conversation.beginReply(to: original, from: self) + conversation.sendWithoutSettling(answer, from: self) + conversation.assertComposerCleared(of: answer) + } + + /// A draft written and sent in one go, with no pause anywhere in it. Typing the body in + /// separate bursts leaves the field mid-edit when the send lands, which is the state the lost + /// clear needs and the one a single `typeText` is least likely to produce on its own. + func testSendingMidEdit_leavesTheComposerEmpty() throws { + try openConversation() + + for attempt in 1...Self.sendCount { + let message = Self.uniqueText("burst \(attempt)") + waitUntilHittableAndTap(conversation.messageField) + + let send = conversation.composerSendButton + for word in message.split(separator: " ") { + conversation.messageField.typeText("\(word) ") + } + send.tap() + + conversation.assertComposerCleared(of: message) + } + } + + // MARK: - Helpers + + /// Opens the account's first chat, skipping the test when it has none — the suite drives an + /// existing conversation rather than creating one, so an empty list is a missing fixture and + /// not a defect. + private func openConversation() throws { + assertMainScreenReached() + + let chats = TipsUIScreen(app: app) + chats.open(from: self) + + guard let row = chats.firstConversationRow(timeout: 30) else { + throw XCTSkip("The test account has no chat conversation — skipping the composer suite") + } + row.tap() + + XCTAssertTrue( + conversation.messageField.waitForExistence(timeout: 30), + "Expected the conversation's composer. On screen: [\(visibleText())]" + ) + } + + /// A per-run body, so a field query can never match text left behind by an earlier run. + private static func uniqueText(_ prefix: String) -> String { + "\(prefix) \(Int(Date().timeIntervalSince1970 * 1000) % 1_000_000)" + } +} diff --git a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift index 74ebce1bb..0147318e1 100644 --- a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift +++ b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift @@ -30,6 +30,10 @@ struct ConversationUIScreen { var deliveredReceipt: XCUIElement { app.staticTexts["Delivered"] } + /// What the field currently shows. An empty `TextField` reports its placeholder here, so an + /// empty composer reads as "Message" (or "Reply" mid-reply), never as "". + var draftValue: String { messageField.value as? String ?? "" } + func messageBubble(_ text: String) -> XCUIElement { app.staticTexts[text] } // MARK: - Reply elements @@ -70,6 +74,18 @@ struct ConversationUIScreen { testCase.waitAndTap(composerSendButton) } + /// Types `text` and sends it without waiting on the send button first. + /// + /// `sendMessage` waits for the button to exist before tapping, which parks a beat between the + /// last keystroke and the send. That beat is exactly the window where a clear can be lost, so a + /// test hunting for it has to resolve the button up front and tap as soon as typing returns. + func sendWithoutSettling(_ text: String, from testCase: BaseUITestCase) { + testCase.waitUntilHittableAndTap(messageField) + let send = composerSendButton + messageField.typeText(text) + send.tap() + } + // MARK: - Reply actions /// Long-presses `text`'s bubble and waits for its context menu. @@ -150,6 +166,29 @@ struct ConversationUIScreen { ) } + /// Asserts the composer no longer holds `text`. + /// + /// Polls rather than reading once, so a clear that merely arrives late still passes and only a + /// clear that never arrives fails. An empty SwiftUI `TextField` reports its placeholder as its + /// value, so "empty" is "no longer contains what was sent" rather than an empty string. + /// + /// The send button's state is what makes a failure diagnosable: it is drawn from the same + /// binding the field is, so a field still showing `text` with the button already gone means the + /// binding emptied and the update never reached the text view. With the button still up, + /// nothing was sent at all. + func assertComposerCleared(of text: String, timeout: TimeInterval = 5) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !draftValue.contains(text) { return } + Thread.sleep(forTimeInterval: 0.25) + } + + let diagnosis = composerSendButton.exists + ? "the send button is still up, so the send never fired" + : "the send button is gone, so the draft emptied and the field never caught up" + XCTFail("Expected the composer to be empty after sending '\(text)', still shows '\(draftValue)' — \(diagnosis)") + } + /// Asserts no reply is open on the composer. func assertNoReplyStrip(timeout: TimeInterval = 5) { let gone = NSPredicate(format: "exists == false")