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:
Yaojia Wang
2026-07-05 01:04:42 +02:00
parent 5098643355
commit cc4d3129cc
22 changed files with 1752 additions and 106 deletions

View File

@@ -0,0 +1,114 @@
import APIClient
import Foundation
import HostRegistry
import SessionCore
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-15 · Hosted live-server smoke the automated stand-in for the manual
/// attach walkthrough (plan §7 T-iOS-15; the true device tap-through
/// + app-switcher shade visual check remain DEFERRED to T-iOS-18).
///
/// Drives the REAL production DI graph end-to-end against the repo's real
/// Node server: `runPairingProbe` over `URLSessionHTTPTransport` +
/// `URLSessionTermTransport` (WS upgrade with Origin + guarded kill
/// round-trip) Host stored (InMemory per task) `APIClient.liveSessions`
/// `SessionEngine.open(nil)` input echo output marker `close()`.
@Suite("Live-server smoke (production DI graph)", .serialized)
struct LiveServerSmokeTests {
/// Collects engine output off the single-consumer stream.
private actor OutputObserver {
private(set) var adoptedId: UUID?
private var buffer = ""
private static let tailLength = 400
func recordAdopted(_ id: UUID) { adoptedId = id }
/// Appends a chunk; true once the accumulated output contains `marker`.
func appendAndCheck(_ chunk: String, marker: String) -> Bool {
buffer += chunk
return buffer.contains(marker)
}
var tail: String { String(buffer.suffix(Self.tailLength)) }
}
@Test("探针→host 入库→liveSessions→attach(null)→echo→output→close")
func productionGraphEndToEnd() async throws {
let endpoint = try await SimServerHarness.shared.endpoint()
// Production transports the same instances AppEnvironment wires.
let http = URLSessionHTTPTransport()
let ws = URLSessionTermTransport()
// Pairing probe (two-step: RO GET, WS attach + Origin, guarded kill).
let probed: HostEndpoint
switch await runPairingProbe(endpoint: endpoint, http: http, ws: ws) {
case .failure(let error):
Issue.record("pairing probe failed: \(error)")
return
case .success(let validated):
probed = validated
}
// Host stored (InMemory stand-in is explicitly allowed here).
let store = InMemoryHostStore()
let host = HostRegistry.Host(id: UUID(), name: "smoke", endpoint: probed)
_ = try await store.upsert(host)
#expect(try await store.loadAll().map(\.id) == [host.id])
// Session list over the real HTTP path (RO no Origin).
let api = APIClient(endpoint: probed, http: http)
_ = try await api.liveSessions()
// Real engine: attach(null) shell echo observe evaluated marker.
// Arithmetic dedup: the TYPED command contains "$((...))", only the
// EVALUATED output contains the final marker string.
let base = 4200
let offset = Int.random(in: 1...999)
let marker = "smoke-\(base + offset)"
let command = "echo smoke-$((\(base)+\(offset)))\r"
let engine = SessionEngine(
transport: ws, clock: ContinuousClock(), endpoint: probed,
eventsSource: { id in try await api.events(id: id) }
)
let observer = OutputObserver()
let consumeTask = Task { () -> Bool in
for await event in engine.events {
switch event {
case .adopted(let id):
await observer.recordAdopted(id)
case .output(let chunk):
if await observer.appendAndCheck(chunk, marker: marker) { return true }
default:
break
}
}
return false
}
await engine.open(sessionId: nil, cwd: nil)
await engine.send(.input(data: command)) // queues until attach-ready
let watchdog = Task {
try? await Task.sleep(for: SmokeTunables.outputTimeout)
consumeTask.cancel() // AsyncStream iteration honors cancellation
}
let sawMarker = await consumeTask.value
watchdog.cancel()
await engine.close()
let outputTail = await observer.tail
#expect(sawMarker, "output 未包含 \(marker)tail: \(outputTail)")
// Cleanup: kill the smoke session via the guarded G route (Origin).
let adopted = try #require(await observer.adoptedId, "attached 帧未到达")
do {
try await api.killSession(id: adopted)
} catch APIClientError.sessionNotFound {
// Already gone cleanup goal reached.
}
}
}