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.
This commit is contained in:
Yaojia Wang
2026-07-30 16:58:22 +02:00
parent 5cc755b0b6
commit ddab77b337
10 changed files with 1577 additions and 23 deletions

View File

@@ -21,16 +21,29 @@ final class WSTestClient: @unchecked Sendable {
/// - 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?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) {
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")
}
let task = URLSession.shared.webSocketTask(with: request)
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()
@@ -261,6 +274,30 @@ func deleteLiveSession(server: TestServer, id: String, origin: String?) async th
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")