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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View 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`
///
///
/// - 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 }
}
}