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 条")
}
}