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:
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal file
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal 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 条")
|
||||
}
|
||||
}
|
||||
132
ios/App/WebTermTests/KeyBarTests.swift
Normal file
132
ios/App/WebTermTests/KeyBarTests.swift
Normal 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 label→bytes 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))
|
||||
}
|
||||
}
|
||||
433
ios/App/WebTermTests/PairingViewModelTests.swift
Normal file
433
ios/App/WebTermTests/PairingViewModelTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal file
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
280
ios/App/WebTermTests/TerminalViewModelTests.swift
Normal file
280
ios/App/WebTermTests/TerminalViewModelTests.swift
Normal 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))])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user