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
This commit is contained in:
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
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` → 列表行)。
|
||||
///
|
||||
/// 分层:
|
||||
/// - 列表 VM:unread 判定/清除/持久化、标题注册表(净化在两个边界各自执行);
|
||||
/// - TerminalViewModel:delegate 原始标题在此边界净化,adopted 后携 id 上浮;
|
||||
/// - 接线:controller 把 title hook 接到新旧 VM(suspend→resume 重建后不掉线);
|
||||
/// coordinator 的 closeTerminal 记 last-seen 水位、title 流入列表行。
|
||||
/// 单活 WS 不变式:切会话 = close→open(deep-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: - TerminalViewModel:delegate 边界净化 + 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 接到 VM;suspend→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: 重建没有弄丢 hook,id 仍是 adopted id。
|
||||
#expect(forwarded.map(\.1) == ["gen0", "gen1"])
|
||||
#expect(forwarded.allSatisfy { $0.0 == adopted })
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("coordinator:closeTerminal 记 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user