Files
web-terminal/ios/App/WebTermTests/SessionActivityBridgeTests.swift
Yaojia Wang cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00

165 lines
6.0 KiB
Swift
Raw 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 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 pendinglifted 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<SessionEvent>.Continuation
let bridge: SessionActivityBridge
private(set) var pendingChanges: [(sessionId: UUID, pending: Bool)] = []
init() {
let (stream, continuation) = AsyncStream<SessionEvent>.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)
}
}