// // WSTestClient.swift — T-iOS-16 集成测试的 WS 客户端与协议流程助手 // // 由 OriginSpikeTests.swift 拆分而来。与 spike 版的关键差异:帧的编/解码一律走 // 冻结契约 `MessageCodec`(T-iOS-3)——集成测试因此同时守护"客户端复刻的协议" // 与服务器实现之间的漂移(plan §9:这是 Origin spike 的常驻化)。 // 默认 `maximumMessageSize = Tunables.maxWSMessageBytes`(16 MiB,plan §3.2.1); // 只有 spike③ 的复现分支显式传 nil 保持平台默认 1 MiB。 import Foundation import WireProtocol #if canImport(FoundationNetworking) import FoundationNetworking #endif // ─── WS 客户端薄封装(每测一个;不可变持有 task) ─────────────────────────── final class WSTestClient: @unchecked Sendable { let task: URLSessionWebSocketTask /// - Parameters: /// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自 /// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。 /// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB); /// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。 init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) { var request = URLRequest(url: server.wsURL) request.timeoutInterval = 15 if let origin { // T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。 request.setValue(origin, forHTTPHeaderField: "Origin") } let task = URLSession.shared.webSocketTask(with: request) if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } self.task = task task.resume() } /// 握手响应状态码(升级被拒时应为 401;成功时 101)。 var handshakeStatusCode: Int? { (task.response as? HTTPURLResponse)?.statusCode } /// 经冻结契约编码后发送(客户端帧永远走 MessageCodec,不手拼 JSON)。 func send(_ message: ClientMessage) async throws { try await task.send(.string(MessageCodec.encode(message))) } /// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。 func receiveText(timeout: Duration) async throws -> String { try await withTimeout(timeout) { [self] in while true { switch try await task.receive() { case .string(let text): return text case .data: continue @unknown default: continue } } } } /// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。 func handshakeFailure(timeout: Duration) async -> (any Error)? { do { _ = try await receiveText(timeout: timeout) return nil } catch { return error } } func close() { task.cancel(with: .normalClosure, reason: nil) } } func withTimeout( _ timeout: Duration, _ operation: @escaping @Sendable () async throws -> T ) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(for: timeout) throw HarnessError.timeout("操作超过 \(timeout)") } guard let first = try await group.next() else { throw HarnessError.timeout("task group 无结果") } group.cancelAll() return first } } // ─── 协议流程助手(解码一律 MessageCodec.decodeServer;非法帧丢弃,同服务器语义) ── /// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。 func shellCommand(_ line: String) -> ClientMessage { .input(data: line + "\r") } /// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached /// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。 func attachAndAwaitAttached(client: WSTestClient, sessionId: UUID?) async throws -> UUID { try await client.send(.attach(sessionId: sessionId, cwd: nil)) let deadline = ContinuousClock.now + HarnessTunables.frameTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout) if case let .attached(id)? = MessageCodec.decodeServer(text) { return id } } throw HarnessError.timeout("attach 后未收到 attached 帧") } /// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。 func accumulateOutputBytes(client: WSTestClient, minBytes: Int) async throws -> Int { var total = 0 let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: HarnessTunables.outputAccumulationTimeout) guard case let .output(data)? = MessageCodec.decodeServer(text) else { continue } total += data.utf8.count if total >= minBytes { return total } } throw HarnessError.timeout("输出累计 \(total)/\(minBytes) 字节后超时") } /// 顺序累积 output 数据、等待标记出现(标记可跨帧,故必须整段累积再查找)。 /// 值类型:每个测试自己持有一份,随用随弃(不可变风格;transcript 只增不改)。 struct TranscriptReader { let client: WSTestClient private(set) var transcript = "" mutating func awaitContains( _ marker: String, timeout: Duration = HarnessTunables.markerTimeout ) async throws { let deadline = ContinuousClock.now + timeout while !transcript.contains(marker) { guard ContinuousClock.now < deadline else { throw HarnessError.timeout( "等 \"\(marker)\" 超时;transcript 尾部: …\(transcript.suffix(200))") } let text = try await client.receiveText(timeout: timeout) if case let .output(data)? = MessageCodec.decodeServer(text) { transcript += data } } } } struct ReplaySummary: Sendable { let adoptedSessionId: UUID let totalOutputBytes: Int let containsSoftReset: Bool } /// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。 func replayResult( client: WSTestClient, sessionId: UUID, minBytes: Int ) async -> Result { do { try await client.send(.attach(sessionId: sessionId, cwd: nil)) var adopted: UUID? var total = 0 var sawSoftReset = false let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout) switch MessageCodec.decodeServer(text) { case let .attached(id)?: adopted = id case let .output(data)?: total += data.utf8.count // 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170 / M2)。 if !sawSoftReset, data.contains(WireConstants.replaySoftResetPrefix) { sawSoftReset = true } default: break } if let adopted, total >= minBytes { return .success(ReplaySummary( adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset)) } } return .failure(HarnessError.timeout( "回放不完整: attached=\(adopted.map(\.uuidString) ?? "无") bytes=\(total)/\(minBytes)")) } catch { return .failure(error) } } /// 读到 socket 结束为止:返回(是否见到 exit 帧,是否观察到 close)。 /// 本地等待超时(HarnessError)≠ close —— 二者显式区分,绝不混淆断言语义。 func drainUntilClose( client: WSTestClient, timeout: Duration = HarnessTunables.closeObserveTimeout ) async -> (sawExit: Bool, closed: Bool) { var sawExit = false let deadline = ContinuousClock.now + timeout while ContinuousClock.now < deadline { do { let text = try await client.receiveText(timeout: timeout) if case .exit? = MessageCodec.decodeServer(text) { sawExit = true } } catch is HarnessError { return (sawExit, false) // 本地超时:socket 还开着 } catch { return (sawExit, true) // receive 抛平台错误 = 流已结束(close/断开) } } return (sawExit, false) } /// 等 exit 帧(自然退出广播路径,src/session/session.ts:146-151)。 func awaitExitFrame( client: WSTestClient, timeout: Duration = HarnessTunables.markerTimeout ) async throws -> (code: Int, reason: String?) { let deadline = ContinuousClock.now + timeout while ContinuousClock.now < deadline { let text = try await client.receiveText(timeout: timeout) if case let .exit(code, reason)? = MessageCodec.decodeServer(text) { return (code, reason) } } throw HarnessError.timeout("未收到 exit 帧") } // ─── 超限失败模式判定(spike③ 复现分支) ──────────────────────────────────── /// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 = /// EMSGSIZE "Message too long";plan §1 勘误 —— 原文误标 ENOBUFS,Darwin 上 /// ENOBUFS 实为 55,两个码都接受)。 func isMessageTooLongError(_ error: any Error) -> Bool { func matches(_ ns: NSError) -> Bool { ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS)) } let ns = error as NSError if matches(ns) { return true } if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) } return false } func describeError(_ error: any Error) -> String { let ns = error as NSError let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError) .map { " underlying=\($0.domain)#\($0.code)" } ?? "" return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)" } // ─── HTTP 助手(G 类端点带 Origin;RO GET 一律不带 —— plan §3.4 铁律) ──────── /// DELETE /live-sessions/:id(G 类,src/server.ts:354-359)。origin=nil 用于 /// 守卫负路径(应得 403)。返回 HTTP 状态码。 func deleteLiveSession(server: TestServer, id: String, origin: String?) async throws -> Int { var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)")) request.httpMethod = "DELETE" if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw HarnessError.setup("DELETE /live-sessions/\(id) 非 HTTP 响应") } return http.statusCode } /// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。 func liveSessionIds(server: TestServer) async throws -> [String] { let url = server.baseURL.appending(path: "live-sessions") let (data, response) = try await URLSession.shared.data(from: url) guard (response as? HTTPURLResponse)?.statusCode == 200, let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { throw HarnessError.setup("GET /live-sessions 非预期形状") } return array.compactMap { $0["id"] as? String } } /// 测后清理:尽力而为 —— 失败只影响宿主残留一个 bash 会话,不影响断言结果, /// 故显式忽略错误(这是唯一允许吞错的地方,且吞的只是清理路径)。 func killSessionBestEffort(server: TestServer, id: UUID) async { _ = try? await deleteLiveSession( server: server, id: id.uuidString.lowercased(), origin: server.origin) }