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:
65
ios/App/WebTermTests/ColdStartPolicyTests.swift
Normal file
65
ios/App/WebTermTests/ColdStartPolicyTests.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Cold-start selection logic (pure): which root screen boots, and
|
||||
/// whether the session list should highlight "继续上次" for the active host.
|
||||
@MainActor
|
||||
@Suite("ColdStartPolicy")
|
||||
struct ColdStartPolicyTests {
|
||||
private final class StubLastSessionStore: LastSessionStore, @unchecked Sendable {
|
||||
private let stored: [UUID: UUID]
|
||||
init(stored: [UUID: UUID] = [:]) { self.stored = stored }
|
||||
func lastSessionId(host: UUID) -> UUID? { stored[host] }
|
||||
func setLastSessionId(_ id: UUID?, host: UUID) {}
|
||||
}
|
||||
|
||||
private static func makeHost(name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
// MARK: - Root route: no paired host → Pairing; else SessionList
|
||||
|
||||
@Test("无配对 host → 冷启动进 Pairing")
|
||||
func noHostsBootsIntoPairing() {
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 0) == .pairing)
|
||||
}
|
||||
|
||||
@Test("有配对 host → 冷启动进 SessionList")
|
||||
func pairedHostsBootIntoSessionList() {
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 1) == .sessions)
|
||||
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 3) == .sessions)
|
||||
}
|
||||
|
||||
// MARK: - "继续上次" highlight
|
||||
|
||||
@Test("active host 存有 lastSessionId → 给出继续上次目标")
|
||||
func continueLastReturnsStoredSession() throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = UUID()
|
||||
let store = StubLastSessionStore(stored: [host.id: sessionId])
|
||||
|
||||
let target = ColdStartPolicy.continueLastSessionId(activeHost: host, store: store)
|
||||
|
||||
#expect(target == sessionId)
|
||||
}
|
||||
|
||||
@Test("该 host 无记录 → 无继续上次高亮")
|
||||
func continueLastNilWithoutRecord() throws {
|
||||
let host = try Self.makeHost()
|
||||
let other = UUID()
|
||||
let store = StubLastSessionStore(stored: [other: UUID()])
|
||||
|
||||
#expect(ColdStartPolicy.continueLastSessionId(activeHost: host, store: store) == nil)
|
||||
}
|
||||
|
||||
@Test("尚无 active host(hosts 未加载/为空)→ 无高亮")
|
||||
func continueLastNilWithoutActiveHost() {
|
||||
let store = StubLastSessionStore()
|
||||
#expect(ColdStartPolicy.continueLastSessionId(activeHost: nil, store: store) == nil)
|
||||
}
|
||||
}
|
||||
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Fan-out adapter tests. `SessionEngine.events` is a
|
||||
/// single-consumer `AsyncStream`, but TerminalViewModel, GateViewModel AND the
|
||||
/// session-activity bridge must all observe it — `EventFanOut` is the wiring
|
||||
/// layer's broadcast seam. These tests pin its contract: every branch receives
|
||||
/// every element, in order, and source termination / cancel() propagate.
|
||||
@Suite("EventFanOut")
|
||||
struct EventFanOutTests {
|
||||
private static let branchCount = 3
|
||||
|
||||
@Test("每个分支都按序收到全部元素")
|
||||
func allBranchesReceiveAllElementsInOrder() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
for value in 1...5 {
|
||||
continuation.yield(value)
|
||||
}
|
||||
continuation.finish()
|
||||
|
||||
// Assert: unbounded buffering — late consumption still sees everything.
|
||||
for branch in fanOut.branches {
|
||||
var received: [Int] = []
|
||||
for await value in branch {
|
||||
received.append(value)
|
||||
}
|
||||
#expect(received == [1, 2, 3, 4, 5])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("分支数量与请求一致")
|
||||
func branchCountMatchesRequest() {
|
||||
let (source, _) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
#expect(fanOut.branches.count == Self.branchCount)
|
||||
}
|
||||
|
||||
@Test("源结束 → 所有分支结束(消费循环退出,不悬挂)")
|
||||
func sourceFinishFinishesEveryBranch() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<String>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
continuation.yield("only")
|
||||
continuation.finish()
|
||||
|
||||
// Assert: every branch loop terminates after draining.
|
||||
for branch in fanOut.branches {
|
||||
var count = 0
|
||||
for await _ in branch {
|
||||
count += 1
|
||||
}
|
||||
#expect(count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("cancel() → 分支立即终止(teardown 不泄漏消费任务)")
|
||||
func cancelTerminatesBranches() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act: cancel without ever finishing the source.
|
||||
fanOut.cancel()
|
||||
continuation.yield(42) // post-cancel input must not hang consumers
|
||||
|
||||
// Assert: iteration completes (content is unspecified mid-flight —
|
||||
// termination is the contract under test).
|
||||
for branch in fanOut.branches {
|
||||
for await _ in branch {}
|
||||
}
|
||||
#expect(Bool(true)) // reaching here = no hang
|
||||
}
|
||||
}
|
||||
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
79
ios/App/WebTermTests/KeychainHostStoreLiveTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Security
|
||||
import Testing
|
||||
import WireProtocol
|
||||
|
||||
/// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7).
|
||||
///
|
||||
/// `swift test` binaries are unsigned → the data-protection keychain answers
|
||||
/// `errSecMissingEntitlement` (-34018), so the package layer only tests the
|
||||
/// store against a fake `SecItemShim`. THIS bundle runs signed inside the
|
||||
/// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised
|
||||
/// end-to-end and the §5.3 protection class is asserted on the stored item.
|
||||
@Suite("KeychainHostStore (real keychain, signed app host)", .serialized)
|
||||
struct KeychainHostStoreLiveTests {
|
||||
/// Test-only service/account — never collides with the production item.
|
||||
private static let service = "com.yaojia.webterm.host-registry.livetests"
|
||||
private static let account = "hosts-live-roundtrip"
|
||||
|
||||
private static func makeHost() throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: "http://192.168.1.9:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint)
|
||||
}
|
||||
|
||||
/// Removes the test item regardless of test outcome.
|
||||
private static func deleteTestItem() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
@Test("真 Keychain 往返(upsert/loadAll/remove)+ kSecAttrAccessible 属性断言")
|
||||
func realKeychainRoundtripAndProtectionClass() async throws {
|
||||
Self.deleteTestItem() // clean slate from any earlier aborted run
|
||||
defer { Self.deleteTestItem() } // never leave the test item behind
|
||||
|
||||
// Arrange: REAL shim (default init), isolated service/account.
|
||||
let store = KeychainHostStore(service: Self.service, account: Self.account)
|
||||
let host = try Self.makeHost()
|
||||
|
||||
// Act + Assert: upsert → visible via loadAll.
|
||||
let afterUpsert = try await store.upsert(host)
|
||||
#expect(afterUpsert == [host])
|
||||
#expect(try await store.loadAll() == [host])
|
||||
|
||||
// Assert §5.3: the STORED item's protection class, straight from
|
||||
// SecItemCopyMatching attributes (not from our own spec constants).
|
||||
var result: CFTypeRef?
|
||||
let attributesQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: Self.account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result)
|
||||
#expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)")
|
||||
let attributes = try #require(result as? [String: Any])
|
||||
let accessible = try #require(attributes[kSecAttrAccessible as String] as? String)
|
||||
#expect(
|
||||
accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
|
||||
"stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)"
|
||||
)
|
||||
// §5.3: never iCloud-synced — the item must not be synchronizable.
|
||||
let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false
|
||||
#expect(!synchronizable)
|
||||
|
||||
// Act + Assert: remove → empty store (item deleted).
|
||||
let afterRemove = try await store.remove(id: host.id)
|
||||
#expect(afterRemove.isEmpty)
|
||||
#expect(try await store.loadAll() == [])
|
||||
}
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
29
ios/App/WebTermTests/PrivacyShadeTests.swift
Normal file
29
ios/App/WebTermTests/PrivacyShadeTests.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Privacy-shade state mapping (plan §7 T-iOS-15, security-critical).
|
||||
///
|
||||
/// The rule under test is EXACT: the shade covers whenever
|
||||
/// `scenePhase != .active` — NOT just `.inactive`. Entering the app switcher
|
||||
/// is `.inactive`, but the moment iOS writes the switcher snapshot to disk is
|
||||
/// `.background`; covering only one of the two leaks terminal content
|
||||
/// (API keys / tokens / source) into the on-disk snapshot.
|
||||
@MainActor
|
||||
@Suite("PrivacyShadePolicy")
|
||||
struct PrivacyShadeTests {
|
||||
@Test("scenePhase == .active → 遮罩隐藏(恢复终端)")
|
||||
func activeShowsTerminal() {
|
||||
#expect(!PrivacyShadePolicy.isShadeVisible(for: .active))
|
||||
}
|
||||
|
||||
@Test("scenePhase == .inactive → 遮罩可见(切换器入口态)")
|
||||
func inactiveIsCovered() {
|
||||
#expect(PrivacyShadePolicy.isShadeVisible(for: .inactive))
|
||||
}
|
||||
|
||||
@Test("scenePhase == .background → 遮罩可见(快照写盘时刻)")
|
||||
func backgroundIsCovered() {
|
||||
#expect(PrivacyShadePolicy.isShadeVisible(for: .background))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
246
ios/App/WebTermTests/SimServerHarness.swift
Normal file
246
ios/App/WebTermTests/SimServerHarness.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// SimServerHarness.swift — T-iOS-15 hosted live-server smoke 的基础设施。
|
||||
//
|
||||
// WebTermTests 跑在【模拟器内的签名 App 宿主】里:iOS SDK 没有
|
||||
// Foundation.Process,但模拟器进程本质是宿主 mac 上的原生进程 ——
|
||||
// `posix_spawn`(iOS 2.0+ 可用,见 SDK spawn.h)在模拟器上直接工作,
|
||||
// 模拟器也能直达宿主 loopback(App 的 ATS 127.0.0.0/8 例外已在 project.yml)。
|
||||
//
|
||||
// 生命周期与 ios/IntegrationTests/ServerHarness.swift 同思路(death-pipe):
|
||||
// 只 spawn 一个 /bin/sh,它把真服务器(node_modules/.bin/tsx src/server.ts)
|
||||
// 拉起为后台子进程后阻塞在 `read`(stdin = 本测试进程持有写端的管道)。
|
||||
// 测试进程无论以何种方式退出,写端被内核关闭 → `read` 得 EOF → sh `kill` 服务器。
|
||||
// 无孤儿、无需 atexit。
|
||||
//
|
||||
// 运行模式(同 ServerHarness 的 A/B 两式):
|
||||
// A. 自举(默认):repo 不在 TCC 保护目录时,测试进程内 posix_spawn 起真服务器。
|
||||
// B. 外部服务器:TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>
|
||||
// (TEST_RUNNER_ 前缀是 xcodebuild 向测试进程透传 env 的官方通道)。
|
||||
// repo 根可用 TEST_RUNNER_WEBTERM_REPO_ROOT 覆盖(默认由 #filePath 推导)。
|
||||
//
|
||||
// TCC 硬墙(T-iOS-15 实测,2026-07-05):repo 位于 ~/Documents|Desktop|Downloads
|
||||
// 时模式 A 必挂——模拟器 App 语境 spawn 的 node 打开该目录下文件会被 tccd 无限
|
||||
// 阻塞(sample 实测主线程卡 uv_cwd → __open_nocancel;sh 内 head 同样挂起;
|
||||
// /bin/sh、/bin/date 等平台二进制不受影响)。此时 harness 快速失败并给出模式 B
|
||||
// 的可操作指引,绝不无声挂 60 秒。CI checkout(如 /Users/runner/work)不受影响。
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
enum SmokeTunables {
|
||||
static let serverReadyTimeout: Duration = .seconds(60)
|
||||
static let serverReadyPollInterval: Duration = .milliseconds(200)
|
||||
/// bash 冷启动 + echo 往返的总预算。
|
||||
static let outputTimeout: Duration = .seconds(45)
|
||||
/// tsx shebang 是 `#!/usr/bin/env node` —— sh 命令里显式前置常见 node 安装位。
|
||||
static let spawnPathPrefix = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
||||
}
|
||||
|
||||
enum SmokeHarnessError: Error, CustomStringConvertible {
|
||||
case setup(String)
|
||||
case timeout(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .setup(let detail): return "setup: \(detail)"
|
||||
case .timeout(let detail): return "timeout: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// spawn 一次、全部用例复用(缓存 in-flight Task,避免 actor 可重入期间重复 spawn)。
|
||||
actor SimServerHarness {
|
||||
static let shared = SimServerHarness()
|
||||
private var bootTask: Task<HostEndpoint, any Error>?
|
||||
/// death-pipe 写端:持有到测试进程退出(内核代为关闭 → 服务器被回收)。
|
||||
private var deathPipeWriteFD: Int32?
|
||||
|
||||
func endpoint() async throws -> HostEndpoint {
|
||||
if let bootTask { return try await bootTask.value }
|
||||
let task = Task { try await self.bootstrap() }
|
||||
bootTask = task
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
private func bootstrap() async throws -> HostEndpoint {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let external = env["WEBTERM_SERVER_URL"] {
|
||||
guard let url = URL(string: external), let endpoint = HostEndpoint(baseURL: url) else {
|
||||
throw SmokeHarnessError.setup("WEBTERM_SERVER_URL 不是合法 http(s) URL: \(external)")
|
||||
}
|
||||
try await Self.awaitReady(endpoint, logPath: nil)
|
||||
return endpoint
|
||||
}
|
||||
|
||||
let repoRoot = Self.locateRepoRoot(environment: env)
|
||||
if let folder = Self.tccProtectedFolder(containing: repoRoot.path) {
|
||||
throw SmokeHarnessError.setup(Self.tccGuidance(folder: folder, repoRoot: repoRoot.path))
|
||||
}
|
||||
let tsxPath = repoRoot.appending(path: "node_modules/.bin/tsx").path
|
||||
guard FileManager.default.isExecutableFile(atPath: tsxPath) else {
|
||||
throw SmokeHarnessError.setup("找不到 \(tsxPath) — 先在 repo 根目录跑 npm install/npm ci")
|
||||
}
|
||||
let port = try Self.findFreeLoopbackPort()
|
||||
guard let baseURL = URL(string: "http://127.0.0.1:\(port)"),
|
||||
let endpoint = HostEndpoint(baseURL: baseURL) else {
|
||||
throw SmokeHarnessError.setup("无法构造 endpoint, port=\(port)")
|
||||
}
|
||||
let logPath = FileManager.default.temporaryDirectory
|
||||
.appending(path: "webterm-hosted-smoke-\(port).log").path
|
||||
deathPipeWriteFD = try Self.spawnServerShell(
|
||||
repoRoot: repoRoot.path, port: port, logPath: logPath
|
||||
)
|
||||
try await Self.awaitReady(endpoint, logPath: logPath)
|
||||
return endpoint
|
||||
}
|
||||
|
||||
// MARK: - Spawn (posix_spawn + death-pipe)
|
||||
|
||||
/// Returns the pipe WRITE fd the caller must keep open for the server's
|
||||
/// lifetime (closing it — including by process death — kills the server).
|
||||
private static func spawnServerShell(
|
||||
repoRoot: String, port: Int, logPath: String
|
||||
) throws -> Int32 {
|
||||
var pipeFDs: [Int32] = [0, 0]
|
||||
guard pipe(&pipeFDs) == 0 else {
|
||||
throw SmokeHarnessError.setup("pipe() 失败: errno \(errno)")
|
||||
}
|
||||
let (readFD, writeFD) = (pipeFDs[0], pipeFDs[1])
|
||||
|
||||
let command = """
|
||||
cd '\(repoRoot)' || exit 1
|
||||
echo "[harness] sh up $(date +%T)" >> '\(logPath)'
|
||||
PORT=\(port) BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
|
||||
PATH="\(SmokeTunables.spawnPathPrefix):$PATH" \
|
||||
node_modules/.bin/tsx src/server.ts >> '\(logPath)' 2>&1 &
|
||||
SRV=$!
|
||||
read _
|
||||
echo "[harness] death-pipe EOF $(date +%T) — killing $SRV" >> '\(logPath)'
|
||||
kill $SRV 2>/dev/null
|
||||
"""
|
||||
|
||||
var fileActions: posix_spawn_file_actions_t?
|
||||
posix_spawn_file_actions_init(&fileActions)
|
||||
defer { posix_spawn_file_actions_destroy(&fileActions) }
|
||||
posix_spawn_file_actions_adddup2(&fileActions, readFD, 0) // sh 的 stdin = 管道读端
|
||||
posix_spawn_file_actions_addclose(&fileActions, readFD)
|
||||
posix_spawn_file_actions_addclose(&fileActions, writeFD) // 子进程绝不持有写端
|
||||
|
||||
let argv = ["/bin/sh", "-c", command]
|
||||
var argvC: [UnsafeMutablePointer<CChar>?] = argv.map { strdup($0) }
|
||||
argvC.append(nil)
|
||||
// MINIMAL clean env — the simulator app's own environment is poison
|
||||
// for host binaries: its DYLD_ROOT_PATH/DYLD_FALLBACK_* point into the
|
||||
// iOS simulator runtime. /bin/sh is SIP-protected (dyld ignores DYLD_*
|
||||
// there), but /usr/local|homebrew node is NOT and dies at dyld stage
|
||||
// before printing anything. Only PATH + writable HOME/TMPDIR survive.
|
||||
let inherited = ProcessInfo.processInfo.environment
|
||||
let cleanEnv = [
|
||||
"PATH": SmokeTunables.spawnPathPrefix,
|
||||
"HOME": inherited["HOME"] ?? NSTemporaryDirectory(),
|
||||
"TMPDIR": inherited["TMPDIR"] ?? NSTemporaryDirectory(),
|
||||
]
|
||||
var envC: [UnsafeMutablePointer<CChar>?] = cleanEnv
|
||||
.map { strdup("\($0.key)=\($0.value)") }
|
||||
envC.append(nil)
|
||||
defer {
|
||||
for pointer in argvC { free(pointer) }
|
||||
for pointer in envC { free(pointer) }
|
||||
}
|
||||
|
||||
var pid: pid_t = 0
|
||||
let status = posix_spawn(&pid, "/bin/sh", &fileActions, nil, argvC, envC)
|
||||
close(readFD) // 父进程不需要读端
|
||||
guard status == 0 else {
|
||||
close(writeFD)
|
||||
throw SmokeHarnessError.setup("posix_spawn(/bin/sh) 失败: \(status)")
|
||||
}
|
||||
return writeFD
|
||||
}
|
||||
|
||||
// MARK: - Readiness
|
||||
|
||||
private static func awaitReady(_ endpoint: HostEndpoint, logPath: String?) async throws {
|
||||
let probeURL = endpoint.baseURL.appending(path: "live-sessions")
|
||||
let deadline = ContinuousClock.now + SmokeTunables.serverReadyTimeout
|
||||
while ContinuousClock.now < deadline {
|
||||
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
|
||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
||||
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: SmokeTunables.serverReadyPollInterval)
|
||||
}
|
||||
throw SmokeHarnessError.timeout(
|
||||
"服务器 \(endpoint.baseURL) 在 \(SmokeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logPath ?? "n/a")"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - TCC guard (fail fast, actionable — see header)
|
||||
|
||||
private static let tccProtectedFolders: Set<String> = ["Documents", "Desktop", "Downloads"]
|
||||
|
||||
/// `/Users/<name>/(Documents|Desktop|Downloads)/…` → the protected folder
|
||||
/// name, else nil. Host-path heuristic (the sim's own HOME is a container
|
||||
/// path, so the host home cannot come from the environment).
|
||||
static func tccProtectedFolder(containing path: String) -> String? {
|
||||
let components = URL(fileURLWithPath: path).pathComponents
|
||||
guard components.count > 3, components[1] == "Users",
|
||||
tccProtectedFolders.contains(components[3]) else { return nil }
|
||||
return components[3]
|
||||
}
|
||||
|
||||
private static func tccGuidance(folder: String, repoRoot: String) -> String {
|
||||
"""
|
||||
repo 位于 macOS TCC 保护目录(~/\(folder)):模拟器沙箱内 spawn 的 node \
|
||||
读取该目录会被 tccd 无限阻塞(实测 open() 挂起),自举模式必挂。请在宿主机 \
|
||||
自行起服务器后以外部模式重跑:
|
||||
cd '\(repoRoot)' && PORT=<p> BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 npm start &
|
||||
TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<p> xcodebuild … test
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - Locations
|
||||
|
||||
private static func locateRepoRoot(environment: [String: String]) -> URL {
|
||||
if let override = environment["WEBTERM_REPO_ROOT"] {
|
||||
return URL(fileURLWithPath: override, isDirectory: true)
|
||||
}
|
||||
// #filePath = <repo>/ios/App/WebTermTests/SimServerHarness.swift
|
||||
return URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // WebTermTests
|
||||
.deletingLastPathComponent() // App
|
||||
.deletingLastPathComponent() // ios
|
||||
.deletingLastPathComponent() // repo root
|
||||
}
|
||||
|
||||
/// bind(port=0) 拿空闲 loopback 端口(同 IntegrationTests 手法;竞态窗口可忽略)。
|
||||
private static func findFreeLoopbackPort() throws -> Int {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw SmokeHarnessError.setup("socket() 失败: errno \(errno)") }
|
||||
defer { close(fd) }
|
||||
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_port = 0
|
||||
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bound == 0 else { throw SmokeHarnessError.setup("bind() 失败: errno \(errno)") }
|
||||
|
||||
var assigned = sockaddr_in()
|
||||
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &assigned) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getsockname(fd, $0, &length)
|
||||
}
|
||||
}
|
||||
guard named == 0 else { throw SmokeHarnessError.setup("getsockname() 失败: errno \(errno)") }
|
||||
return Int(UInt16(bigEndian: assigned.sin_port))
|
||||
}
|
||||
}
|
||||
@@ -277,4 +277,20 @@ struct TerminalViewModelTests {
|
||||
let frames = await harness.transport.sentFramesByConnection[0]
|
||||
#expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
}
|
||||
|
||||
@Test("lastSentDims: valid resize is remembered for notifyForegrounded; invalid dims never overwrite a good pair")
|
||||
func lastSentDimsTracksOnlyValidResizes() async throws {
|
||||
// Arrange
|
||||
let harness = try Harness()
|
||||
#expect(harness.viewModel.lastSentDims == nil)
|
||||
|
||||
// Act: a valid layout pass, then a bogus one (cols=0 fails Validation).
|
||||
harness.viewModel.sendResize(cols: 120, rows: 40)
|
||||
harness.viewModel.sendResize(cols: 0, rows: 40)
|
||||
await harness.viewModel.waitUntilForwarded(sendCount: 2)
|
||||
|
||||
// Assert: the good pair survives — this is what the wiring feeds to
|
||||
// engine.notifyForegrounded(dims:) on the alive-engine .active hop.
|
||||
#expect(harness.viewModel.lastSentDims == .init(cols: 120, rows: 40))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user