Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func makeRoutingConnectedStore(
pairedMacStore: (any MobilePairedMacStoring)? = nil,
routeKind: CmxAttachTransportKind = .debugLoopback,
terminalLaneProvider: MobileTerminalLaneProvider? = nil,
draftStore: (any TerminalDraftStoring)? = nil,
rpcRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000
) async throws -> MobileShellComposite {
let runtime = RoutingTestRuntime(
Expand All @@ -53,7 +54,8 @@ func makeRoutingConnectedStore(
],
pairedMacStore: pairedMacStore,
identityProvider: StaticIdentityProvider(userID: "routing-user"),
pendingDismissQueue: pendingDismissQueue
pendingDismissQueue: pendingDismissQueue,
draftStore: draftStore
)
// 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the
// Stack token and do not throw insecureManualRoute before reaching the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,63 @@ import Testing
@Suite struct ComposerSubmitRoutingTests {
private static func bytes(_ s: String) -> Data { Data(s.utf8) }

@Test func exposesComposerSendProgressAndSettlement() async throws {
let router = RoutingHostRouter()
let store = try await makeRoutingConnectedStore(router: router)
let terminalID = RoutingHostRouter.terminalA
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
store.addPendingAttachment(Self.bytes("held"), format: "png", forTerminalID: terminalID)
store.terminalInputText = "hello"

await router.setHoldFirstPasteImage(true)
let submit = Task { await store.submitComposer() }
await router.awaitFirstPasteImageReached()

#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sending)

await router.releaseFirstPasteImage()
await submit.value

#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sent)
}

@Test func exposesComposerSendFailure() async throws {
let router = RoutingHostRouter()
let store = try await makeRoutingConnectedStore(router: router)
let terminalID = RoutingHostRouter.terminalA
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: terminalID)

await router.setRejectPasteImage(true)
await store.submitComposer()

#expect(store.terminalSendStatus(forTerminalID: terminalID) == .failed)
}

@Test func restoredFailedDraftKeepsFailureSettlement() async throws {
let router = RoutingHostRouter()
let drafts = InMemoryTerminalDraftStore()
let store = try await makeRoutingConnectedStore(router: router, draftStore: drafts)
let termA = RoutingHostRouter.terminalA
let termB = RoutingHostRouter.terminalB
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
await store.drainDraftOperationsForTesting()
store.terminalInputText = "retry me"
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: termA)

await router.setRejectPasteImage(true)
await store.submitComposer()
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)

store.selectTerminal(MobileTerminalPreview.ID(rawValue: termB))
await store.drainDraftOperationsForTesting()
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
await store.drainDraftOperationsForTesting()

#expect(store.terminalInputText == "retry me")
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)
}

/// Images and text both go to the selected terminal when nothing switches.
@Test func sendsAttachmentsAndTextToSelectedTerminal() async throws {
let router = RoutingHostRouter()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,83 @@
import CmuxMobileRPC
import CmuxMobileShellModel
import Foundation
import Testing

@testable import CmuxMobileShell

@Suite struct TerminalRawInputOrderingTests {
@MainActor
@Test func returnKeyExposesCommandSendProgressAndSettlement() async throws {
let router = RoutingHostRouter()
await router.setHoldFirstTerminalInput(true)
let store = try await makeRoutingConnectedStore(router: router)

store.sendTerminalRawInput(
Data("\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
await router.awaitFirstTerminalInputReached()

#expect(
store.terminalSendStatus(forTerminalID: RoutingHostRouter.terminalA)
== .sending
)

await router.releaseFirstTerminalInput()
#expect(await waitForTerminalSendStatus(
.sent,
store: store,
terminalID: RoutingHostRouter.terminalA
))
}

@MainActor
@Test func rejectedReturnKeyExposesCommandSendFailure() async throws {
let router = RoutingHostRouter()
await router.setRejectTerminalInput(at: 0)
let store = try await makeRoutingConnectedStore(router: router)

store.sendTerminalRawInput(
Data("\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)

#expect(await waitForTerminalSendStatus(
.failed,
store: store,
terminalID: RoutingHostRouter.terminalA
))
}

@MainActor
@Test func secondQueuedReturnOwnsItsFailureSettlement() async throws {
let router = RoutingHostRouter()
await router.setHoldFirstTerminalInput(true)
await router.setRejectTerminalInput(at: 1)
let store = try await makeRoutingConnectedStore(router: router)

store.sendTerminalRawInput(
Data("first\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
await router.awaitFirstTerminalInputReached()
store.sendTerminalRawInput(
Data("second\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)

await router.releaseFirstTerminalInput()
#expect(await waitForTerminalSendStatus(
.failed,
store: store,
terminalID: RoutingHostRouter.terminalA
))
#expect(
await router.recordedTerminalInputs().map(\.text)
== ["first\r", "second\r"]
)
}

@MainActor
@Test func orderedIrohFallbackPipelinesAtMostFourRequests() async throws {
let router = RoutingHostRouter()
Expand Down Expand Up @@ -475,6 +548,23 @@ import Testing
}
}

@MainActor
private func waitForTerminalSendStatus(
_ expected: MobileTerminalSendStatus,
store: MobileShellComposite,
terminalID: String
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(2))
while clock.now < deadline {
if store.terminalSendStatus(forTerminalID: terminalID) == expected {
return true
}
await Task.yield()
}
return false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private actor RawInputBarrierTerminalLane: MobileTerminalLaneConnection {
private var pendingFrames: [MobileTerminalLaneOutputFrame] = []
private var receiveContinuation:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Foundation
public import Foundation

/// A coalescing, back-pressured queue of pending terminal input.
///
Expand All @@ -19,6 +19,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public var terminalID: MobileTerminalPreview.ID
/// The accumulated text for this chunk.
public var text: String
/// The newest Return-terminated send represented by this chunk.
public var sendStatusOperationID: UUID?

/// Creates a pending-input chunk.
/// - Parameters:
Expand All @@ -28,11 +30,13 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public init(
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID,
text: String
text: String,
sendStatusOperationID: UUID? = nil
) {
self.workspaceID = workspaceID
self.terminalID = terminalID
self.text = text
self.sendStatusOperationID = sendStatusOperationID
}
}

Expand All @@ -56,7 +60,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public mutating func enqueue(
_ text: String,
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID
terminalID: MobileTerminalPreview.ID,
sendStatusOperationID: UUID? = nil
) -> MobileTerminalInputEnqueueResult {
guard !text.isEmpty else { return .queued }
let byteCount = text.utf8.count
Expand All @@ -67,13 +72,17 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
last.workspaceID == workspaceID,
last.terminalID == terminalID {
last.text += text
if let sendStatusOperationID {
last.sendStatusOperationID = sendStatusOperationID
}
pendingChunks[pendingChunks.count - 1] = last
} else {
pendingChunks.append(
Chunk(
workspaceID: workspaceID,
terminalID: terminalID,
text: text
text: text,
sendStatusOperationID: sendStatusOperationID
)
)
}
Expand Down Expand Up @@ -120,7 +129,10 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
return Chunk(
workspaceID: pendingChunks[0].workspaceID,
terminalID: pendingChunks[0].terminalID,
text: prefix
text: prefix,
// Settle only after the final piece of a split chunk has been
// handed to the transport.
sendStatusOperationID: nil
Comment thread
cursor[bot] marked this conversation as resolved.
)
}
let chunk = pendingChunks.removeFirst()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// User-visible settlement state for a terminal submission.
///
/// The state is scoped per terminal and covers both the composer paste path and
/// Return-terminated raw terminal commands. Ordinary keystrokes are excluded so
/// typing does not flash transport chrome for every character.
public enum MobileTerminalSendStatus: Equatable, Sendable {
case idle
case sending
case sent
case failed
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import CmuxMobileShellModel
Expand Down Expand Up @@ -105,4 +106,29 @@ import Testing
#expect(buffer.pendingByteCount == 0)
#expect(buffer.nextBatch(maximumByteCount: 4) == nil)
}

@Test func coalescedChunkRetainsNewestSendOperation() {
var buffer = MobileTerminalInputSendBuffer()
let workspaceID = MobileWorkspacePreview.ID(rawValue: "workspace-a")
let terminalID = MobileTerminalPreview.ID(rawValue: "terminal-a")
let firstOperationID = UUID()
let secondOperationID = UUID()

#expect(buffer.enqueue(
"first\r",
workspaceID: workspaceID,
terminalID: terminalID,
sendStatusOperationID: firstOperationID
) == .startDraining)
#expect(buffer.enqueue(
"second\r",
workspaceID: workspaceID,
terminalID: terminalID,
sendStatusOperationID: secondOperationID
) == .queued)

let batch = buffer.nextBatch()
#expect(batch?.text == "first\rsecond\r")
#expect(batch?.sendStatusOperationID == secondOperationID)
}
}
Loading