feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
260
ios/IntegrationTests/ServerHarness.swift
Normal file
260
ios/IntegrationTests/ServerHarness.swift
Normal file
@@ -0,0 +1,260 @@
|
||||
//
|
||||
// ServerHarness.swift — T-iOS-16 集成测试基础设施:真 Node 服务器自举
|
||||
//
|
||||
// 由 T-iOS-2 的 OriginSpikeTests.swift(591 行单文件)拆分而来(plan §7 T-iOS-16
|
||||
// 明确批准 harness + suites 拆分)。本文件只管服务器生命周期:
|
||||
// - spawn 一次本仓库真服务器(`node_modules/.bin/tsx src/server.ts`),全部
|
||||
// suite 复用同一进程(会话彼此独立,测试并行安全);
|
||||
// - 就绪探测(GET /live-sessions 返回 JSON 数组);
|
||||
// - 进程回收:death-pipe watchdog + atexit 双保险(Swift Testing runner 退出
|
||||
// 时不可靠地跑 atexit,watchdog 是主机制)。
|
||||
//
|
||||
// 运行方式(可复现):
|
||||
// A. 默认自举:`swift test --package-path ios/IntegrationTests`
|
||||
// repo 根由 #filePath 推导(可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1
|
||||
// 的空闲端口,SHELL_PATH=/bin/bash(确定性输出)、USE_TMUX=0。无需
|
||||
// ALLOWED_ORIGINS:服务器的 deriveAllowedOrigins 恒包含
|
||||
// http://127.0.0.1:<port>(src/config.ts:187-226)。
|
||||
// 日志落在 $TMPDIR/webterm-integration-server-<port>.log。
|
||||
// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:<port>(须为 loopback,
|
||||
// 这样其自身 origin 天然在白名单内)。
|
||||
//
|
||||
// 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由
|
||||
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
// ─── 常量(无魔法数字;线协议常量一律取 WireProtocol.Tunables/WireConstants) ──
|
||||
|
||||
enum HarnessTunables {
|
||||
/// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。
|
||||
static let bulkOutputBytes = 1_500_000
|
||||
/// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB,
|
||||
/// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。
|
||||
static let escSequenceRepeats = 400_000
|
||||
static let escSequenceRawBytes = escSequenceRepeats * 4
|
||||
/// Origin 失配用端口(若真实端口撞车则退避到 fallback)。
|
||||
static let mismatchedOriginPort = 9999
|
||||
static let mismatchedOriginPortFallback = 9998
|
||||
static let serverReadyTimeout: Duration = .seconds(40)
|
||||
static let serverReadyPollInterval: Duration = .milliseconds(200)
|
||||
/// 单帧等待上限(attach→attached 等短往返)。
|
||||
static let frameTimeout: Duration = .seconds(15)
|
||||
/// 等待某个 shell 输出标记出现的上限(含 bash 冷启动)。
|
||||
static let markerTimeout: Duration = .seconds(30)
|
||||
/// 大流量累计(>1MiB 灌入/回放)的上限。
|
||||
static let outputAccumulationTimeout: Duration = .seconds(90)
|
||||
/// 等待服务器主动关闭 WS 的上限(kill 路径)。
|
||||
static let closeObserveTimeout: Duration = .seconds(30)
|
||||
}
|
||||
|
||||
enum HarnessError: 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ────────────────
|
||||
|
||||
struct TestServer: Sendable {
|
||||
let endpoint: HostEndpoint
|
||||
|
||||
var baseURL: URL { endpoint.baseURL }
|
||||
var wsURL: URL { endpoint.wsURL }
|
||||
/// 白名单内的 Origin —— 由 HostEndpoint 单点派生(plan §5.1,禁止手拼)。
|
||||
var origin: String { endpoint.originHeader }
|
||||
var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) }
|
||||
|
||||
/// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。
|
||||
static func make(baseURL: URL) throws -> TestServer {
|
||||
guard let endpoint = HostEndpoint(baseURL: baseURL) else {
|
||||
throw HarnessError.setup("server URL 必须是带 host 的 http(s),got \(baseURL)")
|
||||
}
|
||||
return TestServer(endpoint: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ────────
|
||||
//
|
||||
// 实测(T-iOS-2):Swift Testing runner 退出时不跑 atexit handler(首轮运行留下
|
||||
// 孤儿 tsx),所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin
|
||||
// 是本测试进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端
|
||||
// 关闭 → watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
||||
|
||||
private enum ServerProcessRegistry {
|
||||
private static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var process: Process?
|
||||
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
||||
nonisolated(unsafe) private static var deathPipe: Pipe?
|
||||
nonisolated(unsafe) private static var installedAtexit = false
|
||||
|
||||
static func register(_ p: Process) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
process = p
|
||||
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
|
||||
guard !installedAtexit else { return }
|
||||
installedAtexit = true
|
||||
atexit { ServerProcessRegistry.killNow() }
|
||||
}
|
||||
|
||||
static func killNow() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let p = process, p.isRunning { p.terminate() }
|
||||
process = nil
|
||||
}
|
||||
|
||||
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
||||
let pipe = Pipe()
|
||||
let watchdog = Process()
|
||||
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
|
||||
watchdog.standardInput = pipe
|
||||
watchdog.standardOutput = FileHandle.nullDevice
|
||||
watchdog.standardError = FileHandle.nullDevice
|
||||
try watchdog.run()
|
||||
return pipe
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器 harness(actor 单例:spawn 一次,全部 suite 复用) ─────────────────
|
||||
//
|
||||
// Swift Testing 默认跨 suite 并行 → 多个 suite 会并发调 server()。actor 是可重入
|
||||
// 的:首个调用在 awaitReady 挂起时,后来者会重入进来 —— 若用"缓存成品"模式就会
|
||||
// 重复 spawn。所以缓存的是 in-flight Task(同步写入,后来者 await 同一个)。
|
||||
|
||||
actor ServerHarness {
|
||||
static let shared = ServerHarness()
|
||||
private var bootTask: Task<TestServer, any Error>?
|
||||
|
||||
func server() async throws -> TestServer {
|
||||
if let bootTask { return try await bootTask.value }
|
||||
let task = Task { try await Self.bootstrap() }
|
||||
bootTask = task
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
private static func bootstrap() async throws -> TestServer {
|
||||
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
|
||||
guard let url = URL(string: external) else {
|
||||
throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
|
||||
}
|
||||
let server = try TestServer.make(baseURL: url)
|
||||
try await awaitReady(server, process: nil, logURL: nil)
|
||||
return server
|
||||
}
|
||||
let spawned = try spawnLocalServer()
|
||||
ServerProcessRegistry.register(spawned.process)
|
||||
do {
|
||||
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL)
|
||||
} catch {
|
||||
ServerProcessRegistry.killNow()
|
||||
throw error
|
||||
}
|
||||
return spawned.server
|
||||
}
|
||||
|
||||
private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) {
|
||||
let repoRoot = try locateRepoRoot()
|
||||
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
|
||||
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
|
||||
throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci")
|
||||
}
|
||||
let port = try findFreeLoopbackPort()
|
||||
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
|
||||
throw HarnessError.setup("无法构造 baseURL, port=\(port)")
|
||||
}
|
||||
let server = try TestServer.make(baseURL: baseURL)
|
||||
|
||||
let logURL = FileManager.default.temporaryDirectory
|
||||
.appending(path: "webterm-integration-server-\(port).log")
|
||||
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
||||
let logHandle = try FileHandle(forWritingTo: logURL)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = tsx
|
||||
process.arguments = ["src/server.ts"]
|
||||
process.currentDirectoryURL = repoRoot
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PORT"] = String(port)
|
||||
env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0
|
||||
env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置)
|
||||
env["USE_TMUX"] = "0"
|
||||
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
||||
process.environment = env
|
||||
process.standardOutput = logHandle
|
||||
process.standardError = logHandle
|
||||
try process.run()
|
||||
return (server, process, logURL)
|
||||
}
|
||||
|
||||
private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws {
|
||||
let probeURL = server.baseURL.appending(path: "live-sessions")
|
||||
let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout
|
||||
while ContinuousClock.now < deadline {
|
||||
if let process, !process.isRunning {
|
||||
throw HarnessError.setup(
|
||||
"服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
|
||||
}
|
||||
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: HarnessTunables.serverReadyPollInterval)
|
||||
}
|
||||
throw HarnessError.timeout(
|
||||
"服务器 \(server.baseURL) 在 \(HarnessTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
|
||||
}
|
||||
}
|
||||
|
||||
func locateRepoRoot() throws -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
|
||||
return URL(fileURLWithPath: override, isDirectory: true)
|
||||
}
|
||||
// #filePath = <repo>/ios/IntegrationTests/ServerHarness.swift
|
||||
return URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // IntegrationTests
|
||||
.deletingLastPathComponent() // ios
|
||||
.deletingLastPathComponent() // repo root
|
||||
}
|
||||
|
||||
/// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略,
|
||||
/// 若真撞车 awaitReady 会用日志报错)。
|
||||
func findFreeLoopbackPort() throws -> Int {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw HarnessError.setup("socket() 失败: errno \(errno)") }
|
||||
defer { close(fd) }
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = 0
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
let bound = withUnsafePointer(to: &addr) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") }
|
||||
|
||||
var out = sockaddr_in()
|
||||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &out) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) }
|
||||
}
|
||||
guard named == 0 else { throw HarnessError.setup("getsockname() 失败: errno \(errno)") }
|
||||
return Int(UInt16(bigEndian: out.sin_port))
|
||||
}
|
||||
Reference in New Issue
Block a user