Files
web-terminal/ios/App/WebTermTests/SessionListViewModelTests.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00

584 lines
24 KiB
Swift

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,
unreadStore: InMemoryUnreadWatermarkStore(), // T-iOS-23 seam
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(),
unreadStore: InMemoryUnreadWatermarkStore(),
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)
}
}