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:
114
ios/App/WebTermTests/LiveServerSmokeTests.swift
Normal file
114
ios/App/WebTermTests/LiveServerSmokeTests.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user