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

@@ -19,6 +19,16 @@
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> 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-3Origin/wsURL
// `HostEndpoint` plan §5.1
@@ -51,6 +61,9 @@ enum HarnessTunables {
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 {
@@ -65,6 +78,37 @@ enum HarnessError: Error, CustomStringConvertible {
}
}
// 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..<length).map { _ -> Character in
alphabet[Int.random(in: 0..<alphabet.count, using: &generator)]
}
return String(characters)
}
// HostEndpoint
struct TestServer: Sendable {
@@ -94,16 +138,21 @@ struct TestServer: Sendable {
private enum ServerProcessRegistry {
private static let lock = NSLock()
nonisolated(unsafe) private static var process: Process?
/// F3 +
/// register deathPipe deinit
/// watchdog EOF
nonisolated(unsafe) private static var processes: [Process] = []
/// deinit watchdog
nonisolated(unsafe) private static var deathPipe: Pipe?
nonisolated(unsafe) private static var deathPipes: [Pipe] = []
nonisolated(unsafe) private static var installedAtexit = false
static func register(_ p: Process) {
lock.lock()
defer { lock.unlock() }
process = p
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
processes.append(p)
if let pipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) {
deathPipes.append(pipe)
}
guard !installedAtexit else { return }
installedAtexit = true
atexit { ServerProcessRegistry.killNow() }
@@ -112,8 +161,8 @@ private enum ServerProcessRegistry {
static func killNow() {
lock.lock()
defer { lock.unlock() }
if let p = process, p.isRunning { p.terminate() }
process = nil
for p in processes where p.isRunning { p.terminate() }
processes = []
}
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
@@ -138,6 +187,7 @@ private enum ServerProcessRegistry {
actor ServerHarness {
static let shared = ServerHarness()
private var bootTask: Task<TestServer, any Error>?
private var tokenBootTask: Task<TokenGatedServer, any Error>?
func server() async throws -> TestServer {
if let bootTask { return try await bootTask.value }
@@ -146,19 +196,28 @@ actor ServerHarness {
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)
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)
try await awaitReady(
spawned.server, process: spawned.process, logURL: spawned.logURL, cookie: nil)
} catch {
ServerProcessRegistry.killNow()
throw error
@@ -166,12 +225,71 @@ actor ServerHarness {
return spawned.server
}
private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) {
let repoRoot = try locateRepoRoot()
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci")
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)")
@@ -193,6 +311,9 @@ actor ServerHarness {
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
@@ -200,15 +321,20 @@ actor ServerHarness {
return (server, process, logURL)
}
private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws {
let probeURL = server.baseURL.appending(path: "live-sessions")
/// - Parameter cookie: `Cookie: webterm_auth=`
/// `GET /live-sessions` 401src/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 URLSession.shared.data(from: probeURL),
if let (data, response) = try? await cookieFreeSession.data(for: probe),
(response as? HTTPURLResponse)?.statusCode == 200,
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
return
@@ -220,6 +346,45 @@ actor ServerHarness {
}
}
/// `node_modules/.bin/tsx` repo ****
///
/// `.claude/worktrees/<name>/` 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=<token>` **** `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)