Files
web-terminal/ios/App/WebTermTests/TerminalViewModelTests.swift
Yaojia Wang cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00

297 lines
13 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))])
}
@Test("lastSentDims: valid resize is remembered for notifyForegrounded; invalid dims never overwrite a good pair")
func lastSentDimsTracksOnlyValidResizes() async throws {
// Arrange
let harness = try Harness()
#expect(harness.viewModel.lastSentDims == nil)
// Act: a valid layout pass, then a bogus one (cols=0 fails Validation).
harness.viewModel.sendResize(cols: 120, rows: 40)
harness.viewModel.sendResize(cols: 0, rows: 40)
await harness.viewModel.waitUntilForwarded(sendCount: 2)
// Assert: the good pair survives this is what the wiring feeds to
// engine.notifyForegrounded(dims:) on the alive-engine .active hop.
#expect(harness.viewModel.lastSentDims == .init(cols: 120, rows: 40))
}
}