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. } } }