Files
web-terminal/ios/App/WebTermTests/SessionSwitcherTests.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

412 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-23 · unread `lastOutputAt` vs
/// `UnreadLedger` + App UserDefaults + OSC SwiftTerm
/// `setTerminalTitle` delegate `TitleSanitizer`
///
///
/// - VMunread //
/// - TerminalViewModeldelegate adopted id
/// - 线controller title hook VMsuspendresume 线
/// coordinator closeTerminal last-seen title
/// WS = closeopendeep-link `openDeepLinkedSession`
/// `closeTerminal` engine 线
@MainActor
@Suite("SessionSwitcher (T-iOS-23)")
struct SessionSwitcherTests {
private nonisolated static let base = "http://192.168.1.5:3000"
private nonisolated static let nowMs = 1_700_000_100_000
// MARK: - Fixtures
private func makeHost() throws -> HostRegistry.Host {
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
}
private func listURL() throws -> URL {
try #require(URL(string: "\(Self.base)/live-sessions"))
}
private func sessionJSON(id: UUID, lastOutputAt: Int? = nil, cwd: String = "/Users/dev/proj") -> String {
let lastOutputJSON = lastOutputAt.map { ",\"lastOutputAt\":\($0)" } ?? ""
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
+ ",\"clientCount\":1,\"status\":\"working\",\"exited\":false"
+ ",\"cwd\":\"\(cwd)\",\"cols\":80,\"rows\":24\(lastOutputJSON)}"
}
private func listBody(_ entries: [String]) -> Data {
Data("[\(entries.joined(separator: ","))]".utf8)
}
private func makeListViewModel(
hosts: [HostRegistry.Host],
http: any HTTPTransport,
unreadStore: any UnreadWatermarkStore
) -> SessionListViewModel {
SessionListViewModel(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
clock: FakeClock(),
unreadStore: unreadStore,
nowMs: { Self.nowMs }
)
}
private func loadOnce(_ viewModel: SessionListViewModel) async {
await viewModel.reloadHosts()
await viewModel.refresh()
}
// MARK: - unread + +
@Test("lastOutputAt 高于水位 → unread 点亮markSeen 熄灭;更新的输出再点亮")
func unreadDotLifecycle() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let store = InMemoryUnreadWatermarkStore()
let sessionId = UUID()
let url = try listURL()
await http.queueSuccess(
url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
)
let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)
// Act & Assert: unread
await loadOnce(viewModel)
#expect(viewModel.rows.first?.isUnread == true)
// Act & Assert:
viewModel.markSeen(sessionId: sessionId)
#expect(viewModel.rows.first?.isUnread == false)
// Act & Assert:
await http.queueSuccess(
url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs + 5_000)])
)
await viewModel.refresh()
#expect(viewModel.rows.first?.isUnread == true)
}
@Test("lastOutputAt 缺席(旧服务器)→ 绝不点灯")
func absentLastOutputAtNeverLights() 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 = makeListViewModel(
hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
)
// Act
await loadOnce(viewModel)
// Assert
#expect(viewModel.rows.first?.isUnread == false)
}
@Test("markSeen 持久化水位;新 VM重启模拟读回后照样已读")
func watermarkPersistsAcrossViewModels() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let store = InMemoryUnreadWatermarkStore()
let sessionId = UUID()
let url = try listURL()
let outputAt = Self.nowMs - 1_000
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
let first = makeListViewModel(hosts: [host], http: http, unreadStore: store)
await loadOnce(first)
// Act:
first.markSeen(sessionId: sessionId)
#expect(store.snapshot[sessionId] == Self.nowMs)
// Arrange: "" VM store
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
let second = makeListViewModel(hosts: [host], http: http, unreadStore: store)
// Act & Assert
await loadOnce(second)
#expect(second.rows.first?.isUnread == false)
}
@Test("点开会话行openSession即记 last-seen —— 点即熄灭")
func openSessionMarksSeen() async throws {
// Arrange
let http = FakeHTTPTransport()
let host = try makeHost()
let store = InMemoryUnreadWatermarkStore()
let sessionId = UUID()
await http.queueSuccess(
url: try listURL(),
body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
)
let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)
await loadOnce(viewModel)
#expect(viewModel.rows.first?.isUnread == true)
// Act
viewModel.openSession(id: sessionId)
// Assert:
#expect(viewModel.openRequest?.sessionId == sessionId)
#expect(viewModel.rows.first?.isUnread == false)
#expect(store.snapshot[sessionId] == Self.nowMs)
}
// MARK: -
@Test("setSessionTitle 净化敌意标题后上行;净化后为空 → 回退 cwd 名title nil")
func sessionTitleRegistrySanitizes() 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 = makeListViewModel(
hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
)
await loadOnce(viewModel)
#expect(viewModel.rows.first?.title == nil)
// Act: RLO + ESC
viewModel.setSessionTitle(sessionId: sessionId, title: "npm \u{202E}run\u{1B}[2J build")
// Assert
#expect(viewModel.rows.first?.title == "npm run[2J build")
// Act & Assert: 退 nil
viewModel.setSessionTitle(sessionId: sessionId, title: "\u{200B}\u{200D}")
#expect(viewModel.rows.first?.title == nil)
}
// MARK: - TerminalViewModeldelegate + adopted id
/// TerminalViewModelTests harness engine over FakeTransport
@MainActor
private final class TerminalHarness {
let transport = FakeTransport()
let engine: SessionEngine
let viewModel: TerminalViewModel
init() throws {
let baseURL = try #require(URL(string: SessionSwitcherTests.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
engine = SessionEngine(
transport: transport, clock: FakeClock(), endpoint: endpoint,
eventsSource: { _ in [] }
)
viewModel = TerminalViewModel(engine: engine, events: engine.events)
viewModel.start()
}
func openAndAdopt(_ id: UUID) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(
frame: #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
)
await viewModel.waitUntilProcessed(eventCount: 3)
}
}
@Test("setTerminalTitle敌意原始标题在 VM 边界净化terminalTitle 暴露净化值并携 adopted id 上浮")
func terminalTitleSanitizedAndForwarded() async throws {
// Arrange
let harness = try TerminalHarness()
let adopted = UUID()
var forwarded: [(UUID, String)] = []
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
await harness.openAndAdopt(adopted)
// Act: SwiftTerm delegate OSC
harness.viewModel.setTerminalTitle(" claude\u{202E}\u{07} · fix bug ")
// Assert
#expect(harness.viewModel.terminalTitle == "claude · fix bug")
#expect(forwarded.count == 1)
#expect(forwarded.first?.0 == adopted)
#expect(forwarded.first?.1 == "claude · fix bug")
}
@Test("adopted 前到达的标题被暂存adopted 后补发一次(不丢、不提前)")
func titleBeforeAdoptionIsHeldThenForwarded() async throws {
// Arrange
let harness = try TerminalHarness()
let adopted = UUID()
var forwarded: [(UUID, String)] = []
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
// Act: 线 attached output
harness.viewModel.setTerminalTitle("early")
#expect(forwarded.isEmpty)
await harness.openAndAdopt(adopted)
// Assert
#expect(forwarded.count == 1)
#expect(forwarded.first?.0 == adopted)
#expect(forwarded.first?.1 == "early")
}
@Test("标题被清空(净化后空串)→ terminalTitle 归 nil上浮空串让注册表清条目")
func clearedTitleForwardsEmpty() async throws {
// Arrange
let harness = try TerminalHarness()
let adopted = UUID()
var forwarded: [(UUID, String)] = []
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
await harness.openAndAdopt(adopted)
harness.viewModel.setTerminalTitle("work")
// Act
harness.viewModel.setTerminalTitle(" ")
// Assert
#expect(harness.viewModel.terminalTitle == nil)
#expect(forwarded.map(\.1) == ["work", ""])
}
// MARK: - 线controller hook+ coordinator
private struct WiringFixture {
let transport: FakeTransport
let http: FakeHTTPTransport
let host: HostRegistry.Host
let unreadStore: InMemoryUnreadWatermarkStore
let environment: AppEnvironment
}
private func makeWiringFixture(suiteName: String) throws -> WiringFixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let transport = FakeTransport()
let http = FakeHTTPTransport()
let unreadStore = InMemoryUnreadWatermarkStore()
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
let defaults = try #require(UserDefaults(suiteName: suiteName))
return WiringFixture(
transport: transport,
http: http,
host: host,
unreadStore: unreadStore,
environment: AppEnvironment(
hostStore: InMemoryHostStore(hosts: [host]),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
unreadStore: unreadStore
)
)
}
private func adopt(
_ controller: TerminalSessionController,
transport: FakeTransport,
sessionId: UUID
) async {
await controller.openTask?.value
await transport.emit(
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
)
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
}
@Test("controller 把 title hook 接到 VMsuspend→resume 重建后新 VM 依然上浮")
func controllerRewiresTitleHookAcrossRebuild() async throws {
// Arrange
let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.rewire")
let adopted = UUID()
var forwarded: [(UUID, String)] = []
let controller = TerminalSessionController(
host: fixture.host, sessionId: nil, environment: fixture.environment,
onPendingChanged: { _, _ in },
onTitleChanged: { forwarded.append(($0, $1)) }
)
controller.start()
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Act: VM
controller.terminalViewModel.setTerminalTitle("gen0")
// Assert
#expect(forwarded.map(\.1) == ["gen0"])
// Act: engine + VM
controller.suspend()
controller.resumeIfNeeded()
await adopt(controller, transport: fixture.transport, sessionId: adopted)
controller.terminalViewModel.setTerminalTitle("gen1")
// Assert: hookid adopted id
#expect(forwarded.map(\.1) == ["gen0", "gen1"])
#expect(forwarded.allSatisfy { $0.0 == adopted })
controller.teardown()
}
@Test("coordinatorcloseTerminal 记 last-seen 水位(切会话/返回列表即已读title 流入列表行")
func coordinatorMarksSeenOnCloseAndRoutesTitles() async throws {
// Arrange
let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.coordinator")
let coordinator = AppCoordinator(environment: fixture.environment)
let adopted = UUID()
await coordinator.sessionList.reloadHosts()
// Act: adopted
coordinator.open(SessionListViewModel.OpenRequest(
id: UUID(), host: fixture.host, sessionId: nil
))
let controller = try #require(coordinator.terminalController)
await adopt(controller, transport: fixture.transport, sessionId: adopted)
// Act: OSC SwiftTerm delegate
controller.terminalViewModel.setTerminalTitle("vim server.ts")
await fixture.http.queueSuccess(
url: try listURL(), body: listBody([sessionJSON(id: adopted)])
)
await coordinator.sessionList.refresh()
#expect(coordinator.sessionList.rows.first?.title == "vim server.ts")
// Act: WS close
coordinator.closeTerminal()
// Assert: adopted last-seen
#expect(coordinator.terminalController == nil)
#expect(fixture.unreadStore.snapshot[adopted] != nil)
}
}
// MARK: - App UnreadWatermarkStore
/// 线SessionListViewModelTests
final class InMemoryUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
private let lock = NSLock()
private var stored: [UUID: Int]
init(_ initial: [UUID: Int] = [:]) {
stored = initial
}
func load() -> [UUID: Int] {
lock.withLock { stored }
}
func save(_ watermarks: [UUID: Int]) {
lock.withLock { stored = watermarks }
}
var snapshot: [UUID: Int] {
lock.withLock { stored }
}
}