T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green, but grep for projects/worktree across the whole test tree returned nothing — no test had ever created or removed a real git worktree against a real server. Six tests on a throwaway git fixture: create/remove/prune verified against both the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and asserting the values the SERVER derives rather than echoing our input (branch feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name must never appear in the path). Includes a differential leg for the guarded-route rule — the same request without Origin is 403 with zero side effects, and with it is 200 — which needs raw URLSession, since APIClient structurally cannot emit an Origin-less guarded request. GET /sessions gets its own HOME-isolated server: history.ts reads os.homedir()/.claude/projects, so against the shared harness the assertions would land on the developer's real Claude history, and in CI the directory is absent so it degrades to [] and proves nothing. Integration tests 26 -> 32, independently re-run.
319 lines
14 KiB
Swift
319 lines
14 KiB
Swift
//
|
||
// 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 单点派生)或刻意构造的失配值。
|
||
/// - accessToken: nil = 不带 `Cookie` 头。带值时手写
|
||
/// `Cookie: webterm_auth=<t>`(§1.1 冻结做法:原生客户端不解析
|
||
/// `Set-Cookie`、不依赖 cookie jar)。F3 需要"合法 cookie + 外域 Origin"
|
||
/// 这种真实客户端造不出来的组合来证明两道门**正交**,故这里保留原始拼装。
|
||
/// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB);
|
||
/// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。
|
||
init(
|
||
server: TestServer, origin: String?, accessToken: String? = nil,
|
||
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")
|
||
}
|
||
if let accessToken {
|
||
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
|
||
}
|
||
// 带 cookie 时走关掉 cookie jar 的会话:手写头必须是唯一权威,
|
||
// 共享 jar 里的 Set-Cookie 不许覆盖它(§1.1)。
|
||
let session = accessToken == nil ? URLSession.shared : cookieFreeSession
|
||
let task = session.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<T: Sendable>(
|
||
_ 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<ReplaySummary, any Error> {
|
||
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
|
||
}
|
||
|
||
/// F3:任意方法 + 任意 Origin/Cookie 组合的裸 HTTP 请求 → 状态码。
|
||
///
|
||
/// 存在的理由与 `WSTestClient(accessToken:)` 相同:真实客户端(APIClient)**不可能**
|
||
/// 造出"合法 cookie + 外域 Origin"这种组合(Origin 恒由 `HostEndpoint` 单点派生),
|
||
/// 而那正是"令牌是加法、不是替代"这一性质的判定式。
|
||
func rawStatusCode(
|
||
server: TestServer, method: String, path: String, origin: String?, accessToken: String?
|
||
) async throws -> Int {
|
||
var request = URLRequest(url: server.baseURL.appending(path: path))
|
||
request.httpMethod = method
|
||
// 与 APIClient 同:Accept 绝不含 text/html,否则服务器按浏览器导航处理,
|
||
// 401 会变成 302→/login(src/server.ts:366-369,459)。
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
|
||
if let accessToken {
|
||
request.setValue(authCookieHeader(token: accessToken), forHTTPHeaderField: "Cookie")
|
||
}
|
||
let (_, response) = try await cookieFreeSession.data(for: request)
|
||
guard let http = response as? HTTPURLResponse else {
|
||
throw HarnessError.setup("\(method) \(path) 非 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)
|
||
}
|