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:
@@ -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-3),Origin/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` 恒 401(src/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)
|
||||
|
||||
Reference in New Issue
Block a user