T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
281 lines
12 KiB
Swift
281 lines
12 KiB
Swift
import Foundation
|
|
import SessionCore
|
|
import TestSupport
|
|
import Testing
|
|
import WireProtocol
|
|
@testable import WebTerm
|
|
|
|
/// T-iOS-11 · TerminalViewModel (plan §7). All tests run against a REAL
|
|
/// `SessionEngine` over `TestSupport.FakeTransport` + `FakeClock` (the task's
|
|
/// documented testability choice): the VM consumes `engine.events` exactly as
|
|
/// production will, with zero real waits and zero network.
|
|
///
|
|
/// Determinism: the VM's internal `waitUntilProcessed(eventCount:)` /
|
|
/// `waitUntilForwarded(sendCount:)` barriers and the `onEventApplied` tap are
|
|
/// the "event reached the VM" signals — never polling, never sleeping.
|
|
@MainActor
|
|
@Suite("TerminalViewModel")
|
|
struct TerminalViewModelTests {
|
|
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
|
|
|
|
private enum ServerFrames {
|
|
static func attached(_ id: UUID) -> String {
|
|
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
|
|
}
|
|
|
|
static func output(_ data: String) -> String {
|
|
#"{"type":"output","data":"\#(data)"}"#
|
|
}
|
|
|
|
static func exit(code: Int, reason: String) -> String {
|
|
#"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"#
|
|
}
|
|
}
|
|
|
|
private struct TestStreamError: Error {}
|
|
|
|
// MARK: - Harness
|
|
|
|
/// Engine over fakes + the VM under test, wired exactly like production
|
|
/// (T-iOS-15 passes `engine.events`; a fan-out branch arrives only when
|
|
/// GateViewModel shares the stream).
|
|
@MainActor
|
|
private final class Harness {
|
|
let transport = FakeTransport()
|
|
let clock = FakeClock()
|
|
let engine: SessionEngine
|
|
let viewModel: TerminalViewModel
|
|
|
|
init() throws {
|
|
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
|
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
|
engine = SessionEngine(
|
|
transport: transport, clock: clock, endpoint: endpoint,
|
|
eventsSource: { _ in [] }
|
|
)
|
|
viewModel = TerminalViewModel(engine: engine, events: engine.events)
|
|
viewModel.start()
|
|
}
|
|
|
|
/// Standard preamble: open → .connecting → .connected → server confirms
|
|
/// the attach. 3 events reach the VM.
|
|
func openAndAdopt(_ id: UUID) async {
|
|
await engine.open(sessionId: nil, cwd: nil)
|
|
await transport.emit(frame: ServerFrames.attached(id))
|
|
await viewModel.waitUntilProcessed(eventCount: 3)
|
|
}
|
|
}
|
|
|
|
/// Records `(banner, phase)` after every applied event — the deterministic
|
|
/// way to observe transient states (`.connecting` is often already
|
|
/// superseded by the time a barrier-based await resumes).
|
|
@MainActor
|
|
private final class StateRecorder {
|
|
private(set) var banners: [TerminalViewModel.ConnectionBanner] = []
|
|
|
|
func attach(to viewModel: TerminalViewModel) {
|
|
viewModel.onEventApplied = { [weak self, weak viewModel] _ in
|
|
guard let self, let viewModel else { return }
|
|
self.banners = self.banners + [viewModel.banner]
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Output forwarding (feed order, MainActor by construction)
|
|
|
|
@Test("output forwards to the terminal sink in arrival order; a late sink flushes the buffer first")
|
|
func outputForwardsInOrderAndLateSinkFlushesBuffer() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
let sessionId = UUID()
|
|
await harness.openAndAdopt(sessionId)
|
|
|
|
// Act: two chunks arrive BEFORE any sink exists (screen not built yet).
|
|
await harness.transport.emit(frame: ServerFrames.output("one"))
|
|
await harness.transport.emit(frame: ServerFrames.output("two"))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
|
|
|
// The sink attaches late (SwiftTerm view just got created): buffered
|
|
// chunks must flush immediately, in order. The sink closure is
|
|
// @MainActor-typed — feed-on-main is compiler-enforced (Swift 6).
|
|
let received = Recorder()
|
|
harness.viewModel.attachTerminalSink { received.append($0) }
|
|
|
|
// Assert: buffered flush preserved order.
|
|
#expect(received.chunks == ["one", "two"])
|
|
|
|
// Act: live output after the sink is attached appends behind it.
|
|
await harness.transport.emit(frame: ServerFrames.output("three"))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 6)
|
|
|
|
// Assert
|
|
#expect(received.chunks == ["one", "two", "three"])
|
|
#expect(harness.viewModel.sessionId == sessionId)
|
|
}
|
|
|
|
@MainActor
|
|
private final class Recorder {
|
|
private(set) var chunks: [String] = []
|
|
func append(_ chunk: String) { chunks = chunks + [chunk] }
|
|
}
|
|
|
|
@Test("start() is idempotent — a second start never double-delivers output")
|
|
func startIsIdempotent() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
harness.viewModel.start() // second call must be a no-op
|
|
await harness.openAndAdopt(UUID())
|
|
let received = Recorder()
|
|
harness.viewModel.attachTerminalSink { received.append($0) }
|
|
|
|
// Act
|
|
await harness.transport.emit(frame: ServerFrames.output("once"))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
|
|
|
// Assert
|
|
#expect(received.chunks == ["once"])
|
|
}
|
|
|
|
// MARK: - Reconnect banner
|
|
|
|
@Test("banner: connecting → hidden on connect → reconnecting(attempt) on drop → hidden again after recovery")
|
|
func bannerFollowsConnectionLifecycle() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
let recorder = StateRecorder()
|
|
recorder.attach(to: harness.viewModel)
|
|
await harness.openAndAdopt(UUID())
|
|
|
|
// Act: the transport drops → engine enters backoff (1s first rung).
|
|
await harness.transport.emitError(TestStreamError())
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
|
|
|
// Assert: the reconnect banner is up, carrying attempt + retry delay.
|
|
#expect(harness.viewModel.banner
|
|
== .reconnecting(attempt: 1, next: .seconds(1)))
|
|
#expect(harness.viewModel.bannerModel
|
|
== .reconnecting(attempt: 1, retryIn: .seconds(1)))
|
|
|
|
// Act: fire the backoff timer — the engine reconnects and re-attaches.
|
|
await harness.clock.waitForSleepers(count: 1)
|
|
harness.clock.advance(by: .seconds(1))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 5)
|
|
|
|
// Assert: banner hidden again; full observed banner timeline is exact
|
|
// (events: connecting, connected, adopted, reconnecting, connected).
|
|
#expect(harness.viewModel.banner == .none)
|
|
#expect(harness.viewModel.bannerModel == nil)
|
|
#expect(recorder.banners == [
|
|
.connecting,
|
|
.none,
|
|
.none,
|
|
.reconnecting(attempt: 1, next: .seconds(1)),
|
|
.none,
|
|
])
|
|
}
|
|
|
|
// MARK: - Non-retryable failure (replayTooLarge)
|
|
|
|
@Test("replayTooLarge → non-retrying failed state with the actionable SCROLLBACK_BYTES copy; no reconnect attempt")
|
|
func replayTooLargeBecomesNonRetryingErrorWithActionableCopy() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
await harness.openAndAdopt(UUID())
|
|
|
|
// Act: the transport fails the way an oversized single-frame replay
|
|
// does (EMSGSIZE → typed error, T-iOS-9).
|
|
await harness.transport.emitError(TermTransportError.replayTooLarge)
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
|
|
|
// Assert: explicit error state with the plan §3.2 actionable copy —
|
|
// NOT a spinner, NOT a reconnecting banner.
|
|
#expect(harness.viewModel.phase
|
|
== .failed(message: TerminalViewModel.replayTooLargeMessage))
|
|
#expect(TerminalViewModel.replayTooLargeMessage
|
|
== "服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限")
|
|
#expect(harness.viewModel.bannerModel
|
|
== .failed(message: TerminalViewModel.replayTooLargeMessage))
|
|
#expect(harness.viewModel.isReadOnly)
|
|
|
|
// Assert: deterministic failure never enters the backoff loop — the
|
|
// one original connect stays the only one, and no retry timer sleeps.
|
|
#expect(await harness.transport.connectAttempts.count == 1)
|
|
#expect(harness.clock.pendingSleeperCount == 0)
|
|
}
|
|
|
|
// MARK: - Exit → read-only
|
|
|
|
@Test("exited → read-only terminal + exit banner; input is dropped, never sent")
|
|
func exitedBecomesReadOnlyAndDropsInput() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
await harness.openAndAdopt(UUID())
|
|
|
|
// Act
|
|
await harness.transport.emit(frame: ServerFrames.exit(code: 0, reason: "bye"))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
|
|
|
// Assert: read-only exit state, surfaced as the exit banner.
|
|
#expect(harness.viewModel.phase == .exited(code: 0, reason: "bye"))
|
|
#expect(harness.viewModel.isReadOnly)
|
|
#expect(harness.viewModel.bannerModel == .exited(code: 0, reason: "bye"))
|
|
|
|
// Act: typing after exit — both key-bar and delegate paths.
|
|
harness.viewModel.send(key: .esc)
|
|
harness.viewModel.sendInput("x")
|
|
|
|
// Assert: dropped at the VM boundary (counted, not forwarded).
|
|
#expect(harness.viewModel.droppedReadOnlyInputCount == 2)
|
|
#expect(harness.viewModel.forwardedSendCount == 0)
|
|
let frames = await harness.transport.sentFramesByConnection[0]
|
|
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
|
}
|
|
|
|
// MARK: - Outbound sends (single ordered pipeline through KeyByteMap)
|
|
|
|
@Test("key + text + resize forward to the engine in submission order; key bytes come from KeyByteMap")
|
|
func keyAndTextSendsForwardInOrderThroughKeyByteMap() async throws {
|
|
// Arrange
|
|
let harness = try Harness()
|
|
await harness.openAndAdopt(UUID())
|
|
|
|
// Act: rapid mixed submissions (two taps around typed text + a fit).
|
|
harness.viewModel.send(key: .esc)
|
|
harness.viewModel.sendInput("abc")
|
|
harness.viewModel.send(key: .enter)
|
|
harness.viewModel.sendResize(cols: 120, rows: 40)
|
|
await harness.viewModel.waitUntilForwarded(sendCount: 4)
|
|
|
|
// Assert: exact wire frames, in order, bytes resolved via KeyByteMap
|
|
// (Esc = 0x1B, Enter = \r — never \n).
|
|
let frames = await harness.transport.sentFramesByConnection[0]
|
|
#expect(frames == [
|
|
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
|
|
MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .esc))),
|
|
MessageCodec.encode(.input(data: "abc")),
|
|
MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .enter))),
|
|
MessageCodec.encode(.resize(cols: 120, rows: 40)),
|
|
])
|
|
}
|
|
|
|
@Test("resize is NOT gated by read-only (engine owns terminal-state dropping)")
|
|
func resizeStillForwardsAfterExit() async throws {
|
|
// Arrange: exited session (read-only for INPUT only).
|
|
let harness = try Harness()
|
|
await harness.openAndAdopt(UUID())
|
|
await harness.transport.emit(frame: ServerFrames.exit(code: 1, reason: "done"))
|
|
await harness.viewModel.waitUntilProcessed(eventCount: 4)
|
|
|
|
// Act: a layout pass after exit still reports dims; the VM forwards and
|
|
// the ENGINE (already terminal) is the layer that drops it.
|
|
harness.viewModel.sendResize(cols: 80, rows: 24)
|
|
await harness.viewModel.waitUntilForwarded(sendCount: 1)
|
|
|
|
// Assert: VM forwarded (no read-only drop); no wire frame appeared
|
|
// because the engine discarded it post-exit.
|
|
#expect(harness.viewModel.droppedReadOnlyInputCount == 0)
|
|
let frames = await harness.transport.sentFramesByConnection[0]
|
|
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
|
}
|
|
}
|