Files
web-terminal/ios/App/WebTermTests/LiveServerSmokeTests.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

115 lines
4.5 KiB
Swift
Raw Permalink 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 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.
}
}
}