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
This commit is contained in:
164
ios/App/WebTermTests/SessionActivityBridgeTests.swift
Normal file
164
ios/App/WebTermTests/SessionActivityBridgeTests.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user