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:
@@ -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 jar)。F3 需要"合法 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→/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")
|
||||
|
||||
Reference in New Issue
Block a user