Files
web-terminal/ios/IntegrationTests/WSTestClient.swift
Yaojia Wang ddab77b337 test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
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.
2026-07-30 16:58:22 +02:00

319 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WSTestClient.swift T-iOS-16 WS
//
// OriginSpikeTests.swift spike /
// `MessageCodec`T-iOS-3""
// plan §9 Origin spike
// `maximumMessageSize = Tunables.maxWSMessageBytes`16 MiBplan §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 jarF3 " 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
}
}
}
}
/// / nilhandshake
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 \r0x0D \nCLAUDE.md gotcha
func shellCommand(_ line: String) -> ClientMessage {
.input(data: line + "\r")
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/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[0msrc/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 40Darwin errno 40 =
/// EMSGSIZE "Message too long"plan §1 ENOBUFSDarwin
/// 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 OriginRO GET plan §3.4
/// DELETE /live-sessions/:idG src/server.ts:354-359origin=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/loginsrc/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-sessionsRO 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)
}