// // 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:(src/config.ts:187-226)。 // 日志落在 $TMPDIR/webterm-integration-server-.log。 // B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, // 这样其自身 origin 天然在白名单内)。 // // F3 追加:**第二台**服务器 —— 开了 `WEBTERM_TOKEN` 的令牌网关服务器 // (`ServerHarness.tokenGatedServer()`)。两台并存是必需的:`POST /auth` 的 // 「204 但没有 Set-Cookie」只在**未启用**鉴权的服务器上才出现,而 401/终态用例 // 只在**启用**的服务器上才出现,同一进程无法同时是两者(令牌只在启动时读一次)。 // A. 默认自举:随机生成一枚符合 §1.1 字符集/长度的**一次性**令牌,经 env // 传给子进程(与真实部署同一通路),仅存在于本进程内存;绝不写日志、 // 绝不进 URL、绝不出现在断言文案里。 // B. 外部服务器:WEBTERM_TOKEN_SERVER_URL + WEBTERM_TOKEN_SERVER_TOKEN // (worktree 无 node_modules 时用:从主 checkout 手工起一台带令牌的服务器)。 // // 与 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) /// F3「零重连」的观察窗口:必须显著大于 `ReconnectMachine` 的第一级退避(1s), /// 这样"没有重连"才是结论而不是"还没轮到"。3.5s 覆盖 1s + 2s 两级。 static let retryObservationWindow: Duration = .milliseconds(3500) } 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)" } } } // ─── 令牌网关服务器(F3;令牌是密级材料,见下方纪律注释) ───────────────────── /// 一台启用了 `WEBTERM_TOKEN` 的服务器 + 打开它的那枚一次性令牌。 /// /// 密级纪律(§1.1):`token` 只在进程内存里传递给 `URLRequest`/`tokenProvider`。 /// **绝不**插进断言文案、日志、URL query —— 用例失败时只报状态码与判定结果。 struct TokenGatedServer: Sendable { let server: TestServer let token: String /// 一枚**错误但形状合法**的令牌(同长度、同字符集)。用于 401 用例:形状 /// 非法的令牌会被客户端自己挡掉(`AuthCookie.headerValue` 返回 nil),那测的 /// 就不是服务器的门了。 var wrongButWellFormedToken: String { // 首字符换成集合内的另一个字符:长度/字符集不变,值必不相同。 let replacement: Character = token.first == "z" ? "y" : "z" return String(replacement) + token.dropFirst() } } /// 生成一次性令牌:32 个字符,取自 §1.1 字符集的 ASCII 字母数字子集 /// (子集足够——测的是服务器的门,不是字符集本身;字符集由 TokenPolicyDriftTests 守)。 func makeThrowawayToken(length: Int = 32) -> String { let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") var generator = SystemRandomNumberGenerator() let characters = (0.. Character in alphabet[Int.random(in: 0.. 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() /// F3:从单值改成数组 —— 现在同时跑两台服务器(普通 + 令牌网关)。若继续用 /// 单值持有,第二次 register 会覆盖第一台的 deathPipe,写端被 deinit 关闭 → /// 第一台的 watchdog 立刻收到 EOF 并把还在用的服务器杀掉。 nonisolated(unsafe) private static var processes: [Process] = [] /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 nonisolated(unsafe) private static var deathPipes: [Pipe] = [] nonisolated(unsafe) private static var installedAtexit = false static func register(_ p: Process) { lock.lock() defer { lock.unlock() } processes.append(p) if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) { deathPipes.append(pipe) } guard !installedAtexit else { return } installedAtexit = true atexit { ServerProcessRegistry.killNow() } } static func killNow() { lock.lock() defer { lock.unlock() } for p in processes where p.isRunning { p.terminate() } processes = [] } 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? private var tokenBootTask: Task? 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 } /// F3:开了 `WEBTERM_TOKEN` 的第二台服务器(同样"起一次、全 suite 复用")。 func tokenGatedServer() async throws -> TokenGatedServer { if let tokenBootTask { return try await tokenBootTask.value } let task = Task { try await Self.bootstrapTokenGated() } tokenBootTask = 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, cookie: nil) return server } let spawned = try spawnLocalServer() ServerProcessRegistry.register(spawned.process) do { try await awaitReady( spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil) } catch { ServerProcessRegistry.killNow() throw error } return spawned.server } private static func bootstrapTokenGated() async throws -> TokenGatedServer { let environment = ProcessInfo.processInfo.environment if let external = environment["WEBTERM_TOKEN_SERVER_URL"] { guard let token = environment["WEBTERM_TOKEN_SERVER_TOKEN"], !token.isEmpty else { throw HarnessError.setup( "设了 WEBTERM_TOKEN_SERVER_URL 就必须同时设 WEBTERM_TOKEN_SERVER_TOKEN") } guard let url = URL(string: external) else { throw HarnessError.setup("WEBTERM_TOKEN_SERVER_URL 不是合法 URL: \(external)") } let server = try TestServer.make(baseURL: url) let gated = TokenGatedServer(server: server, token: token) // 就绪探针必须带 cookie:令牌网关下 GET /live-sessions 无 cookie 是 401。 try await awaitReady( server, process: nil, logURL: nil, cookie: authCookieHeader(token: token)) return gated } let token = makeThrowawayToken() let spawned = try spawnLocalServer(extraEnvironment: ["WEBTERM_TOKEN": token]) ServerProcessRegistry.register(spawned.process) do { try await awaitReady( spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: authCookieHeader(token: token)) } catch { ServerProcessRegistry.killNow() throw error } return TokenGatedServer(server: spawned.server, token: token) } /// T-iOS-32:起一台**一次性**服务器 —— 给需要 bespoke 进程环境的用例用。 /// /// 为什么不能复用 `server()`:`GET /sessions` 的数据源是 /// `os.homedir()/.claude/projects`(src/http/history.ts:81),只有把子进程的 /// `HOME` 换掉才能让它**确定**(否则断言就落在开发者本机的真实 Claude 历史上: /// CI 上那个目录根本不存在 ⇒ 恒 `[]` ⇒ 断言恒真,等于没测;本机上又会把真实 /// 会话内容读进测试进程)。`HOME` 只在启动时经 env 传一次,同一进程无法既是 /// 隔离的又是真实的 —— 与 `tokenGatedServer()` 需要第二台是同一个理由。 /// /// 不进 shared 缓存(每次调用一台),但**照样**登记进 `ServerProcessRegistry`, /// 所以 death-pipe watchdog 一样管它,测试进程无论怎么死都不留孤儿 tsx。 /// 就绪失败时只 `terminate()` 自己这一台 —— 绝不调 `killNow()`(那会连别的 /// suite 正在用的共享服务器一起杀掉)。 /// /// 只走自举一条路:外部服务器(`WEBTERM_SERVER_URL`)的 `HOME` 不在我们手里, /// 拿不到本方法存在的理由,所以这里不接受外部模式。 static func disposableServer(extraEnvironment: [String: String]) async throws -> TestServer { let spawned = try spawnLocalServer(extraEnvironment: extraEnvironment) ServerProcessRegistry.register(spawned.process) do { try await awaitReady( spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil) } catch { spawned.process.terminate() throw error } return spawned.server } private static func spawnLocalServer( extraEnvironment: [String: String] = [:] ) throws -> (server: TestServer, process: Process, logURL: URL) { let repoRoot = try locateRepoRoot() let tsx = try locateTsx(from: repoRoot) 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" // F3:令牌经 env 传给子进程 —— 与真实部署完全同一通路(`WEBTERM_TOKEN`), // 且不落盘、不进命令行(`ps` 看不到 env 之外的东西)。 for (key, value) in extraEnvironment { env[key] = value } process.environment = env process.standardOutput = logHandle process.standardError = logHandle try process.run() return (server, process, logURL) } /// - Parameter cookie: 令牌网关服务器的就绪探针必须带 `Cookie: webterm_auth=…`, /// 否则 `GET /live-sessions` 恒 401(src/server.ts:459),会误判成"未就绪"。 private static func awaitReady( _ server: TestServer, process: Process?, logURL: URL?, cookie: String? ) async throws { var probe = URLRequest(url: server.baseURL.appending(path: "live-sessions")) if let cookie { probe.setValue(cookie, forHTTPHeaderField: "Cookie") } 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 cookieFreeSession.data(for: probe), (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")") } } /// `node_modules/.bin/tsx`:从 repo 根**逐级向上**找。 /// /// 为什么向上找:会话工作在 `.claude/worktrees//` 里,worktree 没有自己的 /// `node_modules`;Node 自己的模块解析同样是逐级向上,所以 worktree 里的 /// `src/server.ts` 本来就跑得起来(依赖解析到主 checkout 的 node_modules)。 /// 只查根目录会把这种完全正常的布局误判成"没装依赖"。 func locateTsx(from repoRoot: URL) throws -> URL { var directory = repoRoot.standardizedFileURL var visited: [String] = [] while true { let candidate = directory.appending(path: "node_modules/.bin/tsx") if FileManager.default.isExecutableFile(atPath: candidate.path) { return candidate } visited.append(candidate.path) let parent = directory.deletingLastPathComponent().standardizedFileURL if parent.path == directory.path { break } directory = parent } throw HarnessError.setup( "找不到 node_modules/.bin/tsx(逐级向上查过 \(visited.count) 处," + "从 \(visited.first ?? "?") 起)— 先在 repo 根目录跑 npm install/npm ci") } /// `Cookie: webterm_auth=` 的头**值**(名字取自服务端 `AUTH_COOKIE_NAME`, /// 由 TokenPolicyDriftTests 对钉 src/http/auth.ts)。 func authCookieHeader(token: String) -> String { "webterm_auth=\(token)" } /// 关掉 cookie jar 的会话:手写的 `Cookie` 头是唯一权威(§1.1 冻结),任何主机的 /// `Set-Cookie` 都不许覆盖它、也不许把密级材料留在共享存储里 /// (与 `SessionCore.WSConnection.sessionConfiguration` 同一纪律)。 let cookieFreeSession: URLSession = { let configuration = URLSessionConfiguration.ephemeral configuration.httpShouldSetCookies = false configuration.httpCookieAcceptPolicy = .never configuration.httpCookieStorage = nil return URLSession(configuration: configuration) }() func locateRepoRoot() throws -> URL { if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { return URL(fileURLWithPath: override, isDirectory: true) } // #filePath = /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.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.size)) } } guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") } var out = sockaddr_in() var len = socklen_t(MemoryLayout.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)) }