Files
web-terminal/ios/App/WebTermTests/SessionSwitcherTests.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

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: { _ 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 }
}
}