feat(ios): W3 UI layer + integration CI + ntfy docs

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
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -0,0 +1,402 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run
/// against a REAL `SessionEngine` over `TestSupport.FakeTransport` +
/// `FakeClock` (same testability choice as TerminalViewModelTests): the VM
/// consumes `engine.events` exactly as the T-iOS-15 wiring will zero real
/// waits, zero network, and the engine's `GateTracker.canDecide` second-line
/// guard stays live underneath the VM's first-line tap-epoch guard.
@MainActor
@Suite("GateViewModel")
struct GateViewModelTests {
// 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 status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
if let gate { fields.append("\"gate\":\"\(gate)\"") }
if let detail { fields.append("\"detail\":\"\(detail)\"") }
return "{\(fields.joined(separator: ","))}"
}
}
private struct TestStreamError: Error {}
// MARK: - Test doubles
/// Records haptic firings the "exactly once per gate epoch" probe.
@MainActor
private final class HapticRecorder: HapticSignaling {
private(set) var gateArrivalCount = 0
func gateDidArrive() { gateArrivalCount += 1 }
}
// MARK: - Harness
/// Engine over fakes + the VM under test, wired exactly like production.
@MainActor
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let haptics = HapticRecorder()
let engine: SessionEngine
let viewModel: GateViewModel
init(
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
) 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: eventsSource
)
viewModel = GateViewModel(
engine: engine, events: engine.events, haptics: haptics, clock: clock
)
viewModel.start()
}
/// Standard preamble: open .connecting .connected server confirms
/// the attach. 3 events reach the VM.
func openAndAdopt(_ id: UUID = UUID()) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 3)
}
/// Frames the client sent on connection 0 (attach first, then decisions).
func wireFrames() async -> [String] {
let byConnection = await transport.sentFramesByConnection
return byConnection.first ?? []
}
/// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id`
/// the away window that makes the engine emit exactly one digest.
/// Event count after: 7 (3 preamble + reconnecting + connected +
/// adopted + digest).
func reconnectForDigest(_ id: UUID) async {
await transport.emitError(TestStreamError())
await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting
await clock.waitForSleepers(count: 1) // backoff rung parked
clock.advance(by: .seconds(1))
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 7)
}
}
// MARK: - Gate routing (tool banner, plan sheet)
@Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither")
func gateRoutingToolVsPlan() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tool gate rises (epoch 1).
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: banner state only.
#expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1))
#expect(harness.viewModel.planGate == nil)
// Act: gate lifted.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
// Assert: neither surface renders.
#expect(harness.viewModel.toolGate == nil)
#expect(harness.viewModel.planGate == nil)
// Act: plan gate rises (epoch 2).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Assert: sheet state only.
#expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2))
#expect(harness.viewModel.toolGate == nil)
}
// MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86)
@Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere")
func planThreeWayMappingMirrorsWebClient() async throws {
// Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client
// and never consults /config/ui uiConfig.allowAutoMode is reserved
// for a future permission-mode picker (plan §3.1 note).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.planGate?.epoch)
// Act: all three choices against the held gate (web sends per click).
harness.viewModel.decide(.approveAuto, epoch: epoch)
harness.viewModel.decide(.approveReview, epoch: epoch)
harness.viewModel.decide(.keepPlanning, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 3)
// Assert: exact wire frames, in order never raw `auto`.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
MessageCodec.encode(.approve(mode: .default)),
MessageCodec.encode(.reject),
])
}
@Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject")
func toolTwoWayMapping() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.toolGate?.epoch)
// Act
harness.viewModel.decide(.approve, epoch: epoch)
harness.viewModel.decide(.reject, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 2)
// Assert: bare approve (no mode key) then reject.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
MessageCodec.encode(.reject),
])
}
// MARK: - Tap-epoch guard (first line; engine canDecide is the second)
@Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate")
func staleEpochTapIsDroppedNotSent() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tap before any gate ever rose dropped.
harness.viewModel.decide(.approve, epoch: 0)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Act: a tap rendered against gate 1 lands now dropped, not sent.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 2)
#expect(harness.viewModel.forwardedDecisionCount == 0)
// Act: a fresh tap against the CURRENT gate (epoch 2) goes through.
harness.viewModel.decide(.approve, epoch: 2)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert: exactly one approve ever reached the wire.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
])
}
@Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through")
func kindMorphSameEpochDropsForeignAffordance() async throws {
// Arrange: tool gate epoch 1, then a sustained-pending refresh flips
// the kind to plan WITHOUT minting a new epoch (GateTracker semantics).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.viewModel.planGate?.epoch == 1)
// Act: the tap rendered on the old TOOL banner is no longer an offered
// affordance dropped even though the epoch still matches.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Act: a choice from the CURRENT plan sheet goes through.
harness.viewModel.decide(.approveAuto, epoch: 1)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
])
}
// MARK: - Haptics (exactly once per gate epoch)
@Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz")
func hapticFiresExactlyOncePerGateEpoch() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
#expect(harness.haptics.gateArrivalCount == 0)
// Act: rising edge mints epoch 1 one buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: sustained-pending refresh (same epoch, new detail) NO re-buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: gate lifted no buzz for a nil gate.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: a NEW gate (epoch 2) second buzz.
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 7)
#expect(harness.haptics.gateArrivalCount == 2)
}
// MARK: - Away digest (render / fade / expand)
@Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer")
func digestNonZeroRendersAndAllZeroDoesNot() async throws {
// Arrange: one away-window event (far-future `at` passes the `since`
// filter; the engine stamps the disconnect moment with the real Date).
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
// Act
await harness.reconnectForDigest(sessionId)
// Assert: summary state set, collapsed by default.
#expect(harness.viewModel.digest == AwayDigest(
toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [awayEvent]))
#expect(!harness.viewModel.isDigestExpanded)
// Arrange/Act: an empty away timeline `.digest(.empty)`.
let empty = try Harness(eventsSource: { _ in [] })
await empty.openAndAdopt(sessionId)
await empty.reconnectForDigest(sessionId)
// Assert: nothing rendered, and no fade timer was ever scheduled.
#expect(empty.viewModel.digest == nil)
#expect(empty.clock.pendingSleeperCount == 0)
}
@Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock")
func digestAutoFadesAfterDelay() async throws {
// Arrange: a visible digest.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
#expect(harness.viewModel.digest != nil)
// Act: the fade timer parks on the fake clock; fire it.
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: Tunables.digestFadeDelay)
await harness.viewModel.waitUntilFadeCompleted(count: 1)
// Assert
#expect(harness.viewModel.digest == nil)
}
@Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader")
func expandedDigestNeverFadesAndShowsRecent() async throws {
// Arrange: a visible digest with its fade timer parked.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
await harness.clock.waitForSleepers(count: 1)
// Act: the user expands the summary row.
harness.viewModel.expandDigest()
// Assert: expanded, recent entries available, fade timer CANCELLED.
#expect(harness.viewModel.isDigestExpanded)
#expect(harness.viewModel.digest?.recent == [awayEvent])
#expect(harness.clock.pendingSleeperCount == 0)
// Act: even way past the fade delay the digest stays.
harness.clock.advance(by: Tunables.digestFadeDelay * 10)
#expect(harness.viewModel.digest != nil)
// Act: explicit dismiss clears everything.
harness.viewModel.dismissDigest()
#expect(harness.viewModel.digest == nil)
#expect(!harness.viewModel.isDigestExpanded)
}
// MARK: - Component copy (mirror of public/tabs.ts:326-350)
@Test("banner/sheet copy and choice labels mirror the web client exactly")
func componentCopyMirrorsWebClient() {
// Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}".
let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1)
#expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash")
#expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1))
== "Claude wants to use a tool")
#expect(GateChoiceSpec.specs(for: toolGate).map(\.label)
== ["✓ Approve", "✗ Reject"])
#expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance)
== [.approve, .reject])
// Plan sheet: three-way, exact labels and title.
let planGate = GateState(kind: .plan, detail: nil, epoch: 2)
#expect(GateChoiceSpec.specs(for: planGate).map(\.label)
== ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"])
#expect(GateChoiceSpec.specs(for: planGate).map(\.affordance)
== [.approveAuto, .approveReview, .keepPlanning])
#expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?")
}
@Test("digest summary composes non-zero parts only; user-only activity falls back to a count")
func digestSummaryComposition() {
// Arrange / Act / Assert: all four signal parts.
let full = AwayDigest(
toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: [])
#expect(AwayDigestView.summary(for: full)
== "离开期间:工具调用 3 次 · 等待审批 1 次 · 已完成")
// Stuck-only digest.
let stuck = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: [])
#expect(AwayDigestView.summary(for: stuck) == "离开期间:曾卡住")
// Non-empty digest whose counted signals are all zero (e.g. only
// `user` events) still gets a rendered fallback line.
let userOnly = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")])
#expect(AwayDigestView.summary(for: userOnly) == "离开期间:活动 1 条")
}
}

View File

@@ -0,0 +1,132 @@
import SessionCore
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-11 · KeyBar component + hardware key commands (plan §7).
/// The bar's layout mirrors `public/keybar.ts` `KEYBAR_BUTTONS` (order, glyphs,
/// captions, primary flag) and EVERY labelbytes lookup goes through
/// `KeyByteMap` no byte literal exists in the App layer.
@MainActor
@Suite("KeyBar")
struct KeyBarTests {
@MainActor
private final class KeyRecorder {
private(set) var keys: [KeyByteMap.Key] = []
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
func layoutOrderMirrorsWebKeybarButtons() {
let expectedOrder: [KeyByteMap.Key] = [
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
.arrowLeft, .arrowRight, .slash,
]
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
}
@Test("glyph labels mirror the web bar; Esc is the only primary button")
func labelsAndPrimaryFlagMirrorWeb() {
let expectedLabels = [
"Esc", "Esc²", "⇧Tab", "", "", "", "^C", "^R", "^O", "^L",
"^T", "^B", "^D", "Tab", "", "", "/",
]
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
}
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
func everySpecResolvesThroughKeyByteMap() {
for spec in KeyBarLayout.buttons {
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
"no bytes for \(spec.key.rawValue)")
}
}
// MARK: - KeyBarView (UIKit input accessory)
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
func keyBarViewBuildsOneButtonPerSpec() {
// Arrange / Act
let bar = KeyBarView()
// Assert
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
#expect(bar.keyButtons.map(\.accessibilityLabel)
== KeyBarLayout.buttons.map(\.title))
}
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
func tappingButtonsFiresOnKeyInLayoutOrder() {
// Arrange
let bar = KeyBarView()
let recorder = KeyRecorder()
bar.onKey = { recorder.record($0) }
// Act: tap every button once, in order.
for button in bar.keyButtons {
button.sendActions(for: .touchUpInside)
}
// Assert
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
(.esc, UIKeyCommand.inputEscape, []),
(.shiftTab, "\t", .shift),
(.ctrlC, "c", .control),
(.ctrlR, "r", .control),
(.ctrlO, "o", .control),
(.ctrlL, "l", .control),
(.ctrlT, "t", .control),
(.ctrlB, "b", .control),
(.ctrlD, "d", .control),
]
#expect(HardwareKeyCommands.chords.count == expected.count)
for (key, input, modifiers) in expected {
let chord = HardwareKeyCommands.chords.first { $0.key == key }
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
#expect(!KeyByteMap.bytes(for: key).isEmpty)
}
}
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
func buildEmitsPrioritizedCommandsAndRoundTrips() {
// Arrange / Act
let commands = HardwareKeyCommands.build(
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
)
// Assert
#expect(commands.count == HardwareKeyCommands.chords.count)
for command in commands {
#expect(command.wantsPriorityOverSystemBehavior)
let key = HardwareKeyCommands.key(matching: command)
#expect(key != nil, "no chord matches \(String(describing: command.input))")
}
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
== HardwareKeyCommands.chords.count)
}
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
func hardwareMappingExcludesPlainKeysAndArrows() {
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
let excluded: Set<KeyByteMap.Key> = [
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.enter, .tab, .slash, .escEsc,
]
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
#expect(chordKeys.isDisjoint(with: excluded))
}
}

View File

@@ -0,0 +1,433 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
/// domain these tests cover the state mapping around it:
/// scan/manual input confirm gate (ZERO network before the user says go)
/// probe Host into the store + navigate signal, error taxonomy copy +
/// action, and the §5.4 warning tiers.
///
/// Determinism: the probe is injected as a closure; scripted results come from
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
/// `FakeTransport` zero real network, zero real waits.
@MainActor
@Suite("PairingViewModel")
struct PairingViewModelTests {
// MARK: - Probe script (scripted results + invocation recording)
private actor ProbeScript {
private(set) var calls: [HostEndpoint] = []
private var results: [Result<HostEndpoint, PairingError>]
/// Empty `results` = always succeed with the probed endpoint.
init(results: [Result<HostEndpoint, PairingError>] = []) {
self.results = results
}
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
calls = calls + [endpoint]
guard let next = results.first else { return .success(endpoint) }
results = Array(results.dropFirst())
return next
}
}
private func makeViewModel(
store: any HostStore = InMemoryHostStore(),
script: ProbeScript
) -> PairingViewModel {
PairingViewModel(store: store, probe: { await script.invoke($0) })
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { [] }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
// MARK: - Scan confirm gate REAL two-step probe store + navigate
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
func scanConfirmGateThenRealProbePairsHost() async throws {
// Arrange: REAL runPairingProbe over fakes the strongest proof that
// (a) nothing touches the wire before the user confirms and (b) the
// production closure wiring is exercised end to end.
let http = FakeHTTPTransport()
let ws = FakeTransport()
let store = InMemoryHostStore()
let viewModel = PairingViewModel(
store: store,
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
)
let base = "http://192.168.1.5:3000"
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
// Script the full happy path UP FRONT if the VM probed before
// confirm, recordedRequests would already be non-empty below.
await http.queueSuccess(
url: try #require(URL(string: "\(base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
status: 204
)
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
viewModel.handleScannedCode(base)
// Assert: confirm state shows the single-point-derived address and the
// scanned host has seen ZERO network traffic (probe would GET, probe
// would spawn a PTY on the target untrusted scan input, plan §5).
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.displayAddress == base)
#expect(pending.endpoint.originHeader == base)
#expect(pending.warning == .plaintextLAN)
#expect(await http.recordedRequests.isEmpty)
#expect(await ws.connectAttempts.isEmpty)
#expect(viewModel.pairedHost == nil)
// Act: name the host, then confirm ONLY now may the probe run.
viewModel.hostName = "书房 Mac"
await viewModel.confirmConnect()
// Assert: paired + navigate signal; Host{id,name} constructed by the
// VM (§3.4 contract ruling) and upserted into the store.
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.name == "书房 Mac")
#expect(host.endpoint == pending.endpoint)
#expect(viewModel.pairedHost == host)
#expect(try await store.loadAll() == [host])
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
// iff guarded (§3.4 ) and one WS attach round-trip, closed.
let requests = await http.recordedRequests
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
#expect(await ws.connectAttempts.count == 1)
#expect(await ws.closeCallCount == 1)
// Assert: a stray scan cannot preempt a finished pairing.
viewModel.handleScannedCode("http://10.0.0.9:3000")
#expect(viewModel.phase == .paired(host))
}
// MARK: - Input boundary: scan payloads are untrusted external input
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
"ftp://192.168.1.5:3000",
"ws://192.168.1.5:3000",
"javascript:alert(1)",
"WIFI:S:mynet;T:WPA;P:hunter2;;",
"",
])
func scanRejectsNonHTTPPayloads(payload: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.handleScannedCode(payload)
// Assert: stays idle, inline rejection copy, probe never invoked.
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - Manual entry (documented decision: reuses the confirm state)
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
("192.168.1.5:3000", "http://192.168.1.5:3000"),
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
])
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
// surface documented T-iOS-12 decision), still zero probe calls.
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
return
}
#expect(pending.endpoint.originHeader == expectedOrigin)
#expect(await script.calls.isEmpty)
}
@Test("unparseable manual input is rejected with copy", arguments: [
"", " ", "://nope", "http://",
])
func manualEntryRejectsUnparseableInput(input: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - §5.4 warning tiers (shown on the confirm page)
@Test("warning tiers follow the §5.4 table", arguments: [
// public host strongest BLOCKING warning, http AND https alike
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
// ws:// to RFC1918 / link-local / .local non-blocking plaintext notice
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) no plaintext
// warning (WireGuard already encrypts); positive badge instead
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
// loopback none; https to a private-class host none
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
])
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
// Arrange
let baseURL = try #require(URL(string: url))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
// Act & Assert
#expect(PairingViewModel.warning(for: endpoint) == expected)
}
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
func blockingWarningGatesTheProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://203.0.113.7:3000")
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.warning == .publicHostBlocking)
// Act: confirm WITHOUT acknowledging the risk.
await viewModel.confirmConnect()
// Assert: no probe, still confirming, the UI is told to demand the ack.
#expect(await script.calls.isEmpty)
#expect(viewModel.phase == .confirming(pending))
#expect(viewModel.needsPublicRiskAcknowledgement)
// Act: explicit acknowledgement, then confirm again.
viewModel.hasAcknowledgedPublicRisk = true
await viewModel.confirmConnect()
// Assert: probe ran exactly once and pairing completed.
#expect(await script.calls.count == 1)
guard case .paired = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
}
// MARK: - PairingError taxonomy inline copy + recovery action
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
(PairingError.localNetworkDenied,
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
["本地网络", "设置"]),
(PairingError.hostUnreachable(underlying: "Connection refused"),
PairingViewModel.RecoveryAction.retry,
["Connection refused"]),
(PairingError.httpOkButNotWebTerminal,
PairingViewModel.RecoveryAction.retry,
["端口"]),
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
PairingViewModel.RecoveryAction.retry,
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
(PairingError.atsBlocked(host: "198.18.0.1"),
PairingViewModel.RecoveryAction.retry,
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
(PairingError.tlsFailure,
PairingViewModel.RecoveryAction.retry,
["TLS"]),
(PairingError.timeout,
PairingViewModel.RecoveryAction.retry,
["超时"]),
])
func pairingErrorMapsToCopyAndAction(
error: PairingError,
expectedAction: PairingViewModel.RecoveryAction,
requiredFragments: [String]
) async throws {
// Arrange: private-class host so no blocking-warning gate interferes.
let script = ProbeScript(results: [.failure(error)])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
return
}
#expect(failure.action == expectedAction)
#expect(!failure.message.isEmpty)
for fragment in requiredFragments {
#expect(failure.message.contains(fragment),
"copy for \(error) must contain \(fragment)")
}
}
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
func originRejectedHintIsVerbatim() async throws {
// Arrange: the hint the probe derives from endpoint.originHeader is
// already the complete actionable copy never rewrap or re-derive it.
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == hint)
}
// MARK: - Retry / cancel
@Test("retry re-runs the probe against the same endpoint and can succeed")
func retryRerunsProbeAfterFailure() async throws {
// Arrange: first probe times out, second succeeds.
let script = ProbeScript(results: [.failure(.timeout)])
let store = InMemoryHostStore()
let viewModel = makeViewModel(store: store, script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
await viewModel.confirmConnect()
guard case .failed = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
// Act
await viewModel.retry()
// Assert: two probe calls, same endpoint, pairing completed.
let calls = await script.calls
#expect(calls.count == 2)
#expect(calls.first == calls.last)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(try await store.loadAll() == [host])
}
@Test("cancel returns to idle without ever probing")
func cancelReturnsToIdleWithoutProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
viewModel.cancel()
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == nil)
#expect(await script.calls.isEmpty)
}
// MARK: - Store failure is explicit, never silent
@Test("a store failure after a successful probe surfaces an explicit retryable error")
func storeFailureSurfacesExplicitError() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert: failed with the dedicated copy; no navigate signal.
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == PairingCopy.storeFailed)
#expect(failure.action == .retry)
#expect(viewModel.pairedHost == nil)
}
// MARK: - Host naming
@Test("host name defaults to the endpoint host and user names are trimmed")
func hostNameDefaultsAndTrims() async throws {
// Arrange & Act: untouched name default = endpoint host.
let script = ProbeScript()
let storeA = InMemoryHostStore()
let viewModelA = makeViewModel(store: storeA, script: script)
viewModelA.handleScannedCode("http://192.168.1.5:3000")
#expect(viewModelA.hostName == "192.168.1.5")
await viewModelA.confirmConnect()
// Assert
guard case .paired(let defaultNamed) = viewModelA.phase else {
Issue.record("expected .paired, got \(viewModelA.phase)")
return
}
#expect(defaultNamed.name == "192.168.1.5")
// Arrange & Act: user-typed name is trimmed before storing.
let storeB = InMemoryHostStore()
let viewModelB = makeViewModel(store: storeB, script: script)
viewModelB.handleScannedCode("http://192.168.1.5:3000")
viewModelB.hostName = " 书房 Mac "
await viewModelB.confirmConnect()
// Assert
guard case .paired(let userNamed) = viewModelB.phase else {
Issue.record("expected .paired, got \(viewModelB.phase)")
return
}
#expect(userNamed.name == "书房 Mac")
}
}

View File

@@ -0,0 +1,581 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-13 · SessionListViewModel (merged chooser + dashboard, plan §7).
///
/// Covers the task RED list end to end, deterministically (zero real waits):
/// - poll cadence on `FakeClock` (`waitForSleepers` barrier + cancellation
/// leak-check on leave),
/// - status dot mapping for all 5 states + the `pending:true` badge priority
/// (overlay `/live-sessions` carries NO pending field, src/types.ts:246-256),
/// - telemetry staleness at the exact `Tunables.telemetryStaleTtlMs` boundary
/// via an injected now-provider,
/// - swipe-to-kill: optimistic removal proven BEFORE the server replies (gated
/// transport), Origin stamped on the DELETE, rollback on failure,
/// - server order kept with `exited:true` grouped at the bottom,
/// - "+ New session" navigation signal carrying `sessionId: nil`.
///
/// All HTTP goes through the REAL `APIClient` over `FakeHTTPTransport`
/// (task brief: LiveSessionInfo decode exists exercise it, don't stub rows).
@MainActor
@Suite("SessionListViewModel")
struct SessionListViewModelTests {
private nonisolated static let base = "http://192.168.1.5:3000"
/// Fixed "now" injected into the VM staleness must be deterministic.
private nonisolated static let nowMs = 1_700_000_100_000
// MARK: - Fixtures
private func makeHost(
_ urlString: String = SessionListViewModelTests.base,
name: String = "书房 Mac"
) throws -> HostRegistry.Host {
let url = try #require(URL(string: urlString))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
}
private func listURL(_ base: String = SessionListViewModelTests.base) throws -> URL {
try #require(URL(string: "\(base)/live-sessions"))
}
private func deleteURL(
id: UUID, base: String = SessionListViewModelTests.base
) throws -> URL {
try #require(URL(string: "\(base)/live-sessions/\(id.uuidString.lowercased())"))
}
/// One `/live-sessions` entry in the server's exact JSON shape.
private func sessionJSON(
id: UUID,
createdAt: Int = 1_700_000_000_000,
clientCount: Int = 1,
status: String = "working",
exited: Bool = false,
cwd: String? = "/Users/dev/proj",
cols: Int = 80,
rows: Int = 24,
telemetry: String? = nil
) -> String {
let cwdJSON = cwd.map { "\"\($0)\"" } ?? "null"
let telemetryJSON = telemetry.map { ",\"telemetry\":\($0)" } ?? ""
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":\(createdAt)"
+ ",\"clientCount\":\(clientCount),\"status\":\"\(status)\",\"exited\":\(exited)"
+ ",\"cwd\":\(cwdJSON),\"cols\":\(cols),\"rows\":\(rows)\(telemetryJSON)}"
}
private func listBody(_ entries: [String]) -> Data {
Data("[\(entries.joined(separator: ","))]".utf8)
}
private func makeViewModel(
hosts: [HostRegistry.Host],
http: any HTTPTransport,
clock: any Clock<Duration> = FakeClock(),
nowMs: @escaping @Sendable () -> Int = { SessionListViewModelTests.nowMs }
) -> SessionListViewModel {
SessionListViewModel(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
clock: clock,
nowMs: nowMs
)
}
/// Loads hosts + performs one manual fetch the no-polling arrangement
/// used by every test that does not exercise the poll loop itself.
private func loadOnce(_ viewModel: SessionListViewModel) async {
await viewModel.reloadHosts()
await viewModel.refresh()
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { [] }
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
/// HTTPTransport wrapper that parks requests of one method until the test
/// opens the gate the deterministic way to observe the OPTIMISTIC state
/// while the DELETE is still in flight.
private actor GatedHTTPTransport: HTTPTransport {
private let inner: FakeHTTPTransport
private let gatedMethod: String
private var isOpen = false
private var gatedArrivalCount = 0
private var gateOpeners: [CheckedContinuation<Void, Never>] = []
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
init(inner: FakeHTTPTransport, gatedMethod: String) {
self.inner = inner
self.gatedMethod = gatedMethod.uppercased()
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
if (request.httpMethod ?? "GET").uppercased() == gatedMethod {
gatedArrivalCount += 1
for waiter in arrivalWaiters { waiter.resume() }
arrivalWaiters = []
if !isOpen {
await withCheckedContinuation { gateOpeners.append($0) }
}
}
return try await inner.send(request)
}
/// Suspends until at least one gated request has arrived (and parked).
func waitForGatedArrival() async {
guard gatedArrivalCount == 0 else { return }
await withCheckedContinuation { arrivalWaiters.append($0) }
}
func openGate() {
isOpen = true
for opener in gateOpeners { opener.resume() }
gateOpeners = []
}
}
// MARK: - Poll cadence + leave stops cleanly (task RED item 1)
@Test("visible: polls /live-sessions every listPollInterval; leaving cancels the parked sleeper — no leaked task, zero further traffic")
func pollingCadenceAndLeaveStopsCleanly() async throws {
// Arrange: 3 queued list responses one per expected poll tick.
let clock = FakeClock()
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
let sessionId = UUID()
for _ in 0..<3 {
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
}
let viewModel = makeViewModel(hosts: [host], http: http, clock: clock)
// Act: appear (twice idempotence: no second poll loop may spawn).
viewModel.appeared()
viewModel.appeared()
await viewModel.waitUntilFetches(count: 1)
// Assert: first fetch landed and rows are decoded LiveSessionInfo.
#expect(viewModel.rows.map(\.id) == [sessionId])
#expect(viewModel.emptyState == nil)
// Act + Assert: each interval elapsing triggers exactly one more fetch.
await clock.waitForSleepers(count: 1)
clock.advance(by: Tunables.listPollInterval)
await viewModel.waitUntilFetches(count: 2)
await clock.waitForSleepers(count: 1)
clock.advance(by: Tunables.listPollInterval)
await viewModel.waitUntilFetches(count: 3)
// Act: leave the screen while the poll task is parked in sleep.
await clock.waitForSleepers(count: 1)
viewModel.disappeared()
// Assert: cancellation reclaimed the sleeper synchronously (no leaked
// timer) and a long stretch of fake time produces zero new requests.
#expect(clock.pendingSleeperCount == 0)
clock.advance(by: Tunables.listPollInterval * 10)
let requestCount = await http.recordedRequests.count
#expect(requestCount == 3)
}
// MARK: - Empty states
@Test("no paired hosts → .notPaired empty state and ZERO network traffic")
func notPairedEmptyStateWithoutTraffic() async throws {
// Arrange
let http = FakeHTTPTransport()
let viewModel = makeViewModel(hosts: [], http: http)
// Act
viewModel.appeared()
await viewModel.waitUntilFetches(count: 1)
// Assert
#expect(viewModel.emptyState == .notPaired)
#expect(await http.recordedRequests.isEmpty)
viewModel.disappeared()
}
@Test("empty list from the host → .noSessions; a later non-empty fetch clears it")
func noSessionsEmptyStateThenPopulated() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
await http.queueSuccess(url: url, body: listBody([]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act & Assert: loaded + empty .noSessions (distinct from not-paired).
await loadOnce(viewModel)
#expect(viewModel.emptyState == .noSessions)
// Act & Assert: pull-to-refresh path fetches on demand and clears it.
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: UUID())]))
await viewModel.refresh()
#expect(viewModel.rows.count == 1)
#expect(viewModel.emptyState == nil)
}
@Test("host store failure surfaces explicit copy — never a silent empty list")
func hostStoreFailureIsExplicit() async throws {
// Arrange
let viewModel = SessionListViewModel(
hostStore: ThrowingHostStore(),
http: FakeHTTPTransport(),
clock: FakeClock(),
nowMs: { Self.nowMs }
)
// Act
await viewModel.reloadHosts()
// Assert
#expect(viewModel.hosts.isEmpty)
#expect(viewModel.hostsErrorMessage == SessionListCopy.hostsLoadFailed)
}
// MARK: - Status dots (5 states) + pending badge priority
@Test("status dot maps every ClaudeStatus state from the wire", arguments: [
("working", ClaudeStatus.working),
("waiting", ClaudeStatus.waiting),
("idle", ClaudeStatus.idle),
("unknown", ClaudeStatus.unknown),
("stuck", ClaudeStatus.stuck),
])
func statusDotMapsFiveStates(raw: String, expected: ClaudeStatus) async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let sessionId = UUID()
await http.queueSuccess(
url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: raw)])
)
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
#expect(viewModel.rows.first?.indicator == .status(expected))
}
@Test("pending overlay wins over the status dot with a ⚠ badge, and clears back")
func pendingBadgeTakesPriority() async throws {
// Arrange: a WORKING session the strongest contrast to the badge.
let http = FakeHTTPTransport()
let host = try makeHost()
let sessionId = UUID()
await http.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: sessionId, status: "working")])
)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.rows.first?.indicator == .status(.working))
// Act: gate arrives (status frame pending:true T-iOS-15 wiring calls this).
viewModel.setPendingApproval(sessionId: sessionId, pending: true)
// Assert: badge PRIORITY status stays working underneath.
#expect(viewModel.rows.first?.indicator == .pendingApproval)
#expect(viewModel.rows.first?.info.status == .working)
// Act & Assert: gate resolved dot returns.
viewModel.setPendingApproval(sessionId: sessionId, pending: false)
#expect(viewModel.rows.first?.indicator == .status(.working))
}
@Test("indicator glyphs mirror public/tabs.ts claudeIcon, ⚠ for pending")
func indicatorGlyphsMirrorWeb() {
#expect(SessionListViewModel.StatusIndicator.status(.working).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.waiting).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.idle).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.stuck).glyph == "")
#expect(SessionListViewModel.StatusIndicator.status(.unknown).glyph == "")
#expect(SessionListViewModel.StatusIndicator.pendingApproval.glyph == "")
}
// MARK: - Telemetry staleness (injected now-provider, exact boundary)
@Test("telemetry chips grey strictly past telemetryStaleTtlMs; at the boundary they stay live; no telemetry → no chips")
func telemetryStalenessBoundary() async throws {
// Arrange: `at` exactly TTL old (NOT stale web rule is strict `>`,
// public/preview-grid.ts), one 1 ms older (stale), one without telemetry.
let http = FakeHTTPTransport()
let host = try makeHost()
let boundaryId = UUID()
let staleId = UUID()
let bareId = UUID()
let boundaryAt = Self.nowMs - Tunables.telemetryStaleTtlMs
let staleAt = Self.nowMs - Tunables.telemetryStaleTtlMs - 1
await http.queueSuccess(url: try listURL(), body: listBody([
sessionJSON(id: boundaryId, telemetry: "{\"at\":\(boundaryAt),\"costUsd\":0.5}"),
sessionJSON(id: staleId, telemetry: "{\"at\":\(staleAt),\"costUsd\":0.5}"),
sessionJSON(id: bareId),
]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
let rowsById = Dictionary(uniqueKeysWithValues: viewModel.rows.map { ($0.id, $0) })
#expect(rowsById[boundaryId]?.telemetry?.isStale == false)
#expect(rowsById[staleId]?.telemetry?.isStale == true)
#expect(rowsById[bareId]?.telemetry == nil)
}
@Test("chip model mirrors the web gauge: $ cost to 4 places, ctx warn > 80, PR link https-only")
func telemetryChipModelContent() throws {
// Arrange: http:// PR URL the SEC-L5 mirror must refuse to link it.
let telemetry = StatusTelemetry(
contextUsedPct: 91.4,
costUsd: 0.1234,
model: "opus",
pr: PrInfo(number: 7, url: "http://github.com/x/pull/7", reviewState: "approved"),
at: Self.nowMs
)
// Act
let model = try #require(TelemetryChips.Model(telemetry: telemetry, nowMs: Self.nowMs))
// Assert
#expect(model.costText == "$0.1234")
#expect(model.contextText == "ctx 91%")
#expect(model.isContextWarning)
#expect(model.modelName == "opus")
#expect(model.prText == "PR #7")
#expect(model.prReviewState == "approved")
#expect(model.prURL == nil) // http never becomes a tappable link
#expect(!model.isStale)
// Act & Assert: https URL IS linkable; low ctx carries no warning.
let httpsTelemetry = StatusTelemetry(
contextUsedPct: 40,
pr: PrInfo(number: 7, url: "https://github.com/x/pull/7"),
at: Self.nowMs
)
let httpsModel = try #require(
TelemetryChips.Model(telemetry: httpsTelemetry, nowMs: Self.nowMs)
)
#expect(httpsModel.prURL != nil)
#expect(!httpsModel.isContextWarning)
#expect(TelemetryChips.Model(telemetry: nil, nowMs: Self.nowMs) == nil)
}
// MARK: - Ordering: server order kept, exited grouped at the bottom
@Test("rows keep the server's order with exited sessions grouped last, order preserved inside each group")
func orderingGroupsExitedAtBottom() async throws {
// Arrange: server (newest-first) order interleaves exited sessions.
let http = FakeHTTPTransport()
let host = try makeHost()
let liveA = UUID()
let exitedB = UUID()
let liveC = UUID()
let exitedD = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([
sessionJSON(id: liveA),
sessionJSON(id: exitedB, exited: true),
sessionJSON(id: liveC),
sessionJSON(id: exitedD, exited: true),
]))
let viewModel = makeViewModel(hosts: [host], http: http)
// Act
await loadOnce(viewModel)
// Assert
#expect(viewModel.rows.map(\.id) == [liveA, liveC, exitedB, exitedD])
}
// MARK: - Swipe-to-kill: optimistic + Origin + rollback
@Test("kill removes the row optimistically BEFORE the server replies, stamps Origin on the DELETE, stays removed on 204")
func killOptimisticRemovalThenConfirmed() async throws {
// Arrange: DELETE parks at a gate so the in-flight state is observable.
let inner = FakeHTTPTransport()
let gated = GatedHTTPTransport(inner: inner, gatedMethod: "DELETE")
let host = try makeHost()
let targetId = UUID()
let otherId = UUID()
await inner.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
)
await inner.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 204)
let viewModel = makeViewModel(hosts: [host], http: gated)
await loadOnce(viewModel)
#expect(viewModel.rows.map(\.id) == [targetId, otherId])
// Act: swipe-to-kill; hold the DELETE at the gate.
let killTask = Task { await viewModel.kill(sessionId: targetId) }
await gated.waitForGatedArrival()
// Assert: the row is ALREADY gone while the server has not answered.
#expect(viewModel.rows.map(\.id) == [otherId])
#expect(viewModel.killErrorMessage == nil)
// Act: let the 204 through.
await gated.openGate()
await killTask.value
// Assert: still removed; the DELETE carried the exact Origin (G ).
#expect(viewModel.rows.map(\.id) == [otherId])
let deleteRequest = await inner.recordedRequests.first { $0.httpMethod == "DELETE" }
#expect(deleteRequest?.url == (try deleteURL(id: targetId)))
#expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == Self.base)
}
@Test("kill failure rolls the row back at its original position with explicit copy")
func killFailureRollsBack() async throws {
// Arrange: the Origin guard rejects the DELETE (403).
let http = FakeHTTPTransport()
let host = try makeHost()
let targetId = UUID()
let otherId = UUID()
await http.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
)
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 403)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
// Act
await viewModel.kill(sessionId: targetId)
// Assert: rollback restored the ORIGINAL position, copy is actionable.
#expect(viewModel.rows.map(\.id) == [targetId, otherId])
#expect(viewModel.killErrorMessage
== SessionListCopy.killFailed(APIClientError.forbidden.message))
}
@Test("kill on an already-gone session (404) keeps it removed without an error")
func killAlreadyGoneStaysRemoved() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let targetId = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: targetId)]))
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 404)
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
// Act
await viewModel.kill(sessionId: targetId)
// Assert: 404 = session already dead the optimistic removal was right.
#expect(viewModel.rows.isEmpty)
#expect(viewModel.killErrorMessage == nil)
}
// MARK: - Navigation signals
@Test("+ New session emits an OpenRequest with sessionId nil; row taps carry the id; repeats re-fire")
func navigationSignals() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let sessionId = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)]))
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.openRequest == nil)
// Act & Assert: "+ New session" sessionId nil (attach(null) downstream).
viewModel.requestNewSession()
let newRequest = try #require(viewModel.openRequest)
#expect(newRequest.sessionId == nil)
#expect(newRequest.host == host)
// Act & Assert: opening a listed session carries its id.
viewModel.openSession(id: sessionId)
let openRequest = try #require(viewModel.openRequest)
#expect(openRequest.sessionId == sessionId)
#expect(openRequest.host == host)
// Act & Assert: the SAME tap again is a fresh signal (unique id).
viewModel.openSession(id: sessionId)
let repeated = try #require(viewModel.openRequest)
#expect(repeated.sessionId == sessionId)
#expect(repeated.id != openRequest.id)
// Act & Assert: unknown ids are refused at the boundary.
viewModel.openSession(id: UUID())
#expect(viewModel.openRequest?.id == repeated.id)
}
// MARK: - Host switching (multi-host header hook)
@Test("switching hosts refetches from the new endpoint and resets the list")
func hostSwitchRefetches() async throws {
// Arrange: two paired hosts with distinct session lists.
let baseB = "http://10.0.0.7:3000"
let hostA = try makeHost()
let hostB = try makeHost(baseB, name: "工作室 Mac")
let http = FakeHTTPTransport()
let sessionA = UUID()
let sessionB = UUID()
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionA)]))
await http.queueSuccess(
url: try listURL(baseB), body: listBody([sessionJSON(id: sessionB)])
)
let viewModel = makeViewModel(hosts: [hostA, hostB], http: http)
await loadOnce(viewModel)
#expect(viewModel.activeHost == hostA)
#expect(viewModel.rows.map(\.id) == [sessionA])
// Act
await viewModel.selectHost(id: hostB.id)
// Assert: list now comes from host B; navigation carries host B.
#expect(viewModel.activeHost == hostB)
#expect(viewModel.rows.map(\.id) == [sessionB])
viewModel.requestNewSession()
#expect(viewModel.openRequest?.host == hostB)
let lastURL = await http.recordedRequests.last?.url
#expect(lastURL == (try listURL(baseB)))
}
// MARK: - Fetch failure keeps the stale list (explicit error, no wipe)
@Test("a failed poll keeps the last good rows and surfaces copy; the next success clears it")
func fetchFailureKeepsStaleRows() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let url = try listURL()
let sessionId = UUID()
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
await http.queueSuccess(url: url, status: 500)
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
let viewModel = makeViewModel(hosts: [host], http: http)
await loadOnce(viewModel)
#expect(viewModel.rows.map(\.id) == [sessionId])
// Act: the 500 poll.
await viewModel.refresh()
// Assert: stale rows survive, error copy is explicit (includes status).
#expect(viewModel.rows.map(\.id) == [sessionId])
let message = try #require(viewModel.fetchErrorMessage)
#expect(message.contains("500"))
#expect(viewModel.emptyState == nil)
// Act & Assert: recovery clears the error.
await viewModel.refresh()
#expect(viewModel.fetchErrorMessage == nil)
}
}

View File

@@ -0,0 +1,280 @@
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))])
}
}