import Foundation import HostRegistry import SessionCore import Testing import WireProtocol @testable import WebTerm /// T-iOS-15 · Session-activity bridge tests: the third fan-out branch that /// (a) persists the server-adopted sessionId per host (`LastSessionStore`, /// the "继续上次" cold-start source), /// (b) forwards gate pending↔lifted into /// `SessionListViewModel.setPendingApproval` (the ⚠ overlay hook the list /// VM header documents), and /// (c) on exit clears both — a dead session must not stay ⚠ nor be offered /// as "继续上次". @MainActor @Suite("SessionActivityBridge") struct SessionActivityBridgeTests { // MARK: - Test doubles /// Thread-safe in-memory `LastSessionStore` that records every write. final class RecordingLastSessionStore: LastSessionStore, @unchecked Sendable { private let lock = NSLock() private var storage: [UUID: UUID] = [:] private var writes: [(id: UUID?, host: UUID)] = [] func lastSessionId(host: UUID) -> UUID? { lock.lock() defer { lock.unlock() } return storage[host] } func setLastSessionId(_ id: UUID?, host: UUID) { lock.lock() defer { lock.unlock() } storage[host] = id writes = writes + [(id, host)] } var writeCount: Int { lock.lock() defer { lock.unlock() } return writes.count } } // MARK: - Harness @MainActor final class Harness { let hostId = UUID() let store = RecordingLastSessionStore() let continuation: AsyncStream.Continuation let bridge: SessionActivityBridge private(set) var pendingChanges: [(sessionId: UUID, pending: Bool)] = [] init() { let (stream, continuation) = AsyncStream.makeStream() self.continuation = continuation var recorder: (@MainActor (UUID, Bool) -> Void)! let bridge = SessionActivityBridge( events: stream, hostId: hostId, lastSessionStore: store, onPendingChanged: { sessionId, pending in recorder(sessionId, pending) } ) self.bridge = bridge recorder = { [weak self] sessionId, pending in self?.pendingChanges.append((sessionId, pending)) } bridge.start() } } private static func toolGate(epoch: Int) -> GateState { GateState(kind: .tool, detail: "Bash", epoch: epoch) } // MARK: - (a) adopted → persisted last session @Test("adopted → 记录 adoptedSessionId 并按 host 持久化 lastSessionId") func adoptedPersistsLastSession() async { let harness = Harness() let sessionId = UUID() harness.continuation.yield(.adopted(sessionId: sessionId)) await harness.bridge.waitUntilProcessed(eventCount: 1) #expect(harness.bridge.adoptedSessionId == sessionId) #expect(harness.store.lastSessionId(host: harness.hostId) == sessionId) } @Test("重连拿到新 id → 采用并覆盖持久化(永远采用服务器回发的 id)") func readoptionOverwritesPersistedId() async { let harness = Harness() let first = UUID() let second = UUID() harness.continuation.yield(.adopted(sessionId: first)) harness.continuation.yield(.adopted(sessionId: second)) await harness.bridge.waitUntilProcessed(eventCount: 2) #expect(harness.bridge.adoptedSessionId == second) #expect(harness.store.lastSessionId(host: harness.hostId) == second) } // MARK: - (b) gate → pending overlay forwarding @Test("gate 上升/解除 → setPendingApproval(true)/(false) 带 adopted 的 sessionId") func gateForwardsPendingWithAdoptedId() async { let harness = Harness() let sessionId = UUID() harness.continuation.yield(.adopted(sessionId: sessionId)) harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) harness.continuation.yield(.gate(nil)) await harness.bridge.waitUntilProcessed(eventCount: 3) #expect(harness.pendingChanges.count == 2) #expect(harness.pendingChanges[0].sessionId == sessionId) #expect(harness.pendingChanges[0].pending == true) #expect(harness.pendingChanges[1].sessionId == sessionId) #expect(harness.pendingChanges[1].pending == false) } @Test("adopted 之前到达的 gate → 不转发(没有可关联的 sessionId)") func gateBeforeAdoptionIsDropped() async { let harness = Harness() harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) await harness.bridge.waitUntilProcessed(eventCount: 1) #expect(harness.pendingChanges.isEmpty) } // MARK: - (c) exited → clear overlay + last session @Test("exited → 清 ⚠ overlay 且清掉 lastSessionId(死会话不再被『继续上次』指向)") func exitClearsOverlayAndLastSession() async { let harness = Harness() let sessionId = UUID() harness.continuation.yield(.adopted(sessionId: sessionId)) harness.continuation.yield(.gate(Self.toolGate(epoch: 1))) harness.continuation.yield(.exited(code: 0, reason: nil)) await harness.bridge.waitUntilProcessed(eventCount: 3) #expect(harness.pendingChanges.last?.pending == false) #expect(harness.pendingChanges.last?.sessionId == sessionId) #expect(harness.store.lastSessionId(host: harness.hostId) == nil) } @Test("output/telemetry/connection 与本桥无关 → 不写 store、不发 overlay") func unrelatedEventsAreIgnored() async { let harness = Harness() harness.continuation.yield(.output("hello")) harness.continuation.yield(.connection(.connected)) await harness.bridge.waitUntilProcessed(eventCount: 2) #expect(harness.pendingChanges.isEmpty) #expect(harness.store.writeCount == 0) } }