// // WorktreeLifecycleTests.swift — T-iOS-32 缺失的那一半:**端到端一次** // // T-iOS-32("worktree 创建 + claude --resume 历史",PLAN_IOS_CLIENT.md:917)的验收 // 是「builder 测试 + 端到端一次」。builder 侧(WorktreeViewModelTests 22 / // ResumeHistoryViewModelTests 12 / ProjectResumeLaunchTests 7)早就绿了,但在本文件 // 之前,`grep -rn "projects/worktree" ios/IntegrationTests/` **零命中** —— 从没有任何 // 一条测试对着真服务器建过、删过一个真 worktree。这里补的就是这条腿。 // // 三条纪律: // 1. **真**:真 Node 服务器(ServerHarness)+ 真 `APIClient`(不是 fake transport), // 走真 HTTP。 // 2. **不碰用户的仓库**:所有写盘都发生在自建的一次性 git 夹具里(GitFixture), // 连服务器派生出来的 `-worktrees/` 也在夹具的临时根底下,`destroy()` 全删。 // 3. **双信源**:HTTP 200 只说明服务器认为成功;每条断言都再问一次 **git 自己** // (`worktree list --porcelain` / `.git/worktrees/`)和**文件系统**。 // // 服务端真源:src/server.ts:1094-1173(三条路由 + Origin 守卫 + 开关)、 // src/http/worktrees.ts(validate → contain → execFile,无 shell)。 // // 安全面(不可软化):三条路由都是 **G** 类。缺 Origin 必须 403 —— 这是 CSWSH/CSRF // 的唯一防线,而这三条是全应用**唯一**真写盘的通道。任何"为了过 CI 放宽"= CRITICAL。 import Foundation import Testing import WireProtocol @testable import APIClient // ─── 小工具 ────────────────────────────────────────────────────────────────── /// 从 `GitWriteOutcome` 里取出 200 载荷;不是 `.ok` 就**抛**(夹具/前置失败要响亮, /// 不能退化成后续一串看不懂的断言不成立)。 func requireGitWriteOK( _ outcome: GitWriteOutcome, _ what: String ) throws -> Payload { guard case let .ok(payload) = outcome else { throw HarnessError.setup("\(what) 应为 200 .ok,实际 \(outcome)") } return payload } /// 裸 HTTP:任意方法 + 任意 Origin + 真 JSON body → (状态码, 解析后的 body)。 /// /// 为什么不能直接用 `APIClient` 做守卫负路径:真客户端**造不出**"没有 Origin 的 G /// 请求"(`APIRoute.originPolicy == .guarded` 恒由 `HostEndpoint` 盖头,见 §5.1)。 /// 差分的两条腿只差这一个头,其余逐字节相同 —— 403 才能归因到守卫本身。 func rawJSONRequest( server: TestServer, method: String, path: String, origin: String?, json: [String: Any] ) async throws -> (status: Int, body: [String: Any]) { var request = URLRequest(url: server.baseURL.appending(path: path)) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Accept 绝不含 text/html:否则服务器按浏览器导航处理,状态码语义会变。 request.setValue("application/json", forHTTPHeaderField: "Accept") if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } request.httpBody = try JSONSerialization.data(withJSONObject: json) let (data, response) = try await cookieFreeSession.data(for: request) guard let http = response as? HTTPURLResponse else { throw HarnessError.setup("\(method) \(path) 非 HTTP 响应") } let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] return (http.statusCode, parsed ?? [:]) } // ─── 用例 ──────────────────────────────────────────────────────────────────── @Suite("T-iOS-32 worktree 生命周期端到端(真服务器 + 真 APIClient)", .serialized, .timeLimit(.minutes(5))) struct WorktreeLifecycleTests { /// 无令牌的真客户端(默认那台服务器没开 `WEBTERM_TOKEN`)。 private func makeClient(_ server: TestServer) -> APIClient { APIClient(endpoint: server.endpoint, http: IntegrationHTTPTransport(), accessToken: nil) } private let branchWithSlash = "feature/e2e-worktree" private let sanitizedDirName = "feature-e2e-worktree" // MARK: 1. create @Test("create:200 带回**服务端算出来**的 path/branch(目录名被 sanitize),盘上真存在且被 git 登记") func createProducesARealWorktreeRegisteredWithGit() async throws { // Arrange let server = try await ServerHarness.shared.server() let fixture = try GitFixture.make() defer { fixture.destroy() } let client = makeClient(server) // Act let outcome = try await client.createWorktree( path: fixture.repo.path, branch: branchWithSlash, base: nil) // Assert:响应体是 git/服务端的规范值,不是我们输入的回声 let result = try requireGitWriteOK(outcome, "POST /projects/worktree") let createdPath = try #require(result.path, "200 必须带回服务端算出的路径") #expect(result.branch == branchWithSlash, "branch 应原样回来(它就是 git 建的分支名),实际 \(String(describing: result.branch))") // 关键:目录名是 sanitizeBranchForDir 的产物,'/' 变成了 '-'。若这里等于 // 输入回声,说明路径根本不是服务端派生的。 #expect(URL(fileURLWithPath: createdPath).lastPathComponent == sanitizedDirName, "目录名应是 sanitize 后的 \(sanitizedDirName),实际 \(createdPath)") #expect(createdPath.hasPrefix(fixture.worktreeBase.path + "/"), "路径必须落在受控根 \(fixture.worktreeBase.path) 内,实际 \(createdPath)") #expect(createdPath.contains(branchWithSlash) == false, "带 '/' 的原始分支名不该出现在路径里,实际 \(createdPath)") // Assert:文件系统(HTTP 说成功 ≠ 盘上真有) var isDirectory: ObjCBool = false let exists = FileManager.default.fileExists(atPath: createdPath, isDirectory: &isDirectory) #expect(exists && isDirectory.boolValue, "worktree 目录应真存在:\(createdPath)") #expect(FileManager.default.fileExists(atPath: createdPath + "/.git"), "worktree 里应有 .git 指针文件") // Assert:git 自己的登记(第二信源) let registered = try #require( try fixture.registeredWorktree(atCanonicalPath: createdPath), "git worktree list 应登记 \(createdPath)") #expect(registered.branch == branchWithSlash, "git 登记的分支应是 \(branchWithSlash),实际 \(String(describing: registered.branch))") let all = try fixture.registeredWorktrees() #expect(all.count == 2, "应是主 worktree + 新建这一条,实际 \(all)") #expect(fixture.administrativeEntries() == [sanitizedDirName], "行政登记 .git/worktrees/ 应只有这一条,实际 \(fixture.administrativeEntries())") } // MARK: 2. remove @Test("remove:200 后目录从盘上消失、从 git 登记消失、行政目录也被清掉;主 worktree 毫发无损") func removeDeletesTheWorktreeFromDiskAndFromGit() async throws { // Arrange:先真建一个(用真客户端,不走 git 命令行 —— 删的必须是"服务器建的那个") let server = try await ServerHarness.shared.server() let fixture = try GitFixture.make() defer { fixture.destroy() } let client = makeClient(server) let created = try requireGitWriteOK( try await client.createWorktree( path: fixture.repo.path, branch: branchWithSlash, base: nil), "前置 create") let createdPath = try #require(created.path) let before = try fixture.registeredWorktree(atCanonicalPath: createdPath) #expect(before != nil, "前置:git 应先登记了它") // Act:干净 worktree ⇒ 不需要 force let outcome = try await client.removeWorktree( path: fixture.repo.path, worktreePath: createdPath, force: false) // Assert:响应体回的是 git 自己的规范路径 let removed = try requireGitWriteOK(outcome, "DELETE /projects/worktree") let removedPath = try #require(removed.path, "200 必须带回被删的规范路径") #expect(canonicalPath(removedPath) == canonicalPath(createdPath), "删掉的应正是刚建的那条,\(removedPath) vs \(createdPath)") // Assert:盘 + git 两处都没了 #expect(FileManager.default.fileExists(atPath: createdPath) == false, "目录应已从盘上消失:\(createdPath)") let afterRemoval = try fixture.registeredWorktree(atCanonicalPath: createdPath) #expect(afterRemoval == nil, "git worktree list 里不该再有它,实际 \(String(describing: afterRemoval))") #expect(fixture.administrativeEntries().isEmpty, "行政登记应一并清掉,实际 \(fixture.administrativeEntries())") // Assert:主 worktree 还在(destructive 路由绝不能顺手删了仓库本体) let all = try fixture.registeredWorktrees() #expect(all.count == 1, "应只剩主 worktree,实际 \(all)") #expect(canonicalPath(all.first?.path ?? "") == canonicalPath(fixture.repo.path), "剩下的那条必须是仓库本体") #expect(FileManager.default.fileExists(atPath: fixture.repo.path + "/.git")) } // MARK: 3. prune @Test("prune:手工删掉 worktree 目录后,陈旧登记被 git 标 prunable;prune 回收它,再 prune 幂等([])") func pruneReclaimsTheStaleAdministrativeEntry() async throws { // Arrange let server = try await ServerHarness.shared.server() let fixture = try GitFixture.make() defer { fixture.destroy() } let client = makeClient(server) let created = try requireGitWriteOK( try await client.createWorktree( path: fixture.repo.path, branch: branchWithSlash, base: nil), "前置 create") let createdPath = try #require(created.path) // Act 1:绕过服务器,手工把目录删掉 —— 这正是 prune 存在的理由 try FileManager.default.removeItem(atPath: createdPath) // Assert:登记还在,且被 git 标成 prunable("陈旧"是 git 的判定,不是我们的猜测) let stale = try #require( try fixture.registeredWorktree(atCanonicalPath: createdPath), "目录没了但登记应该还在(这才有得 prune)") #expect(stale.prunable, "git 应把它标成 prunable,实际 \(stale)") #expect(fixture.administrativeEntries() == [sanitizedDirName], "行政目录应仍在,实际 \(fixture.administrativeEntries())") // Act 2 let pruned = try requireGitWriteOK( try await client.pruneWorktrees(path: fixture.repo.path), "POST /projects/worktree/prune") // Assert:回收了这一条,并且报出来的标签指向它 #expect(pruned.pruned.count == 1, "应回收 1 条,实际 \(pruned.pruned)") #expect(pruned.pruned.first?.hasSuffix(sanitizedDirName) == true, "回收标签应指向 \(sanitizedDirName),实际 \(pruned.pruned)") let afterPrune = try fixture.registeredWorktree(atCanonicalPath: createdPath) #expect(afterPrune == nil, "陈旧登记应已消失,实际 \(String(describing: afterPrune))") #expect(fixture.administrativeEntries().isEmpty, "行政目录应已清掉,实际 \(fixture.administrativeEntries())") // Assert:幂等 —— 再来一次没得回收,仍是 200 + 空列表 let again = try requireGitWriteOK( try await client.pruneWorktrees(path: fixture.repo.path), "第二次 prune") #expect(again.pruned.isEmpty, "干净仓库再 prune 应为空,实际 \(again.pruned)") } // MARK: 4. G 类守卫(缺 Origin 必须 403,且**什么都没发生**) @Test("G 守卫端到端:三条 worktree 路由缺 Origin 一律 403 且零副作用;仅补上 Origin 的同一请求 → 200") func worktreeRoutesRejectRequestsWithoutOrigin() async throws { // Arrange let server = try await ServerHarness.shared.server() let fixture = try GitFixture.make() defer { fixture.destroy() } let createBody: [String: Any] = ["path": fixture.repo.path, "branch": branchWithSlash] // Act + Assert(create):无 Origin → 403,且**盘上什么都没建** let blockedCreate = try await rawJSONRequest( server: server, method: "POST", path: "projects/worktree", origin: nil, json: createBody) #expect(blockedCreate.status == 403, "缺 Origin 的 create 应 403(src/server.ts:1096),实际 \(blockedCreate.status)") #expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false, "被守卫拒掉的请求不该在盘上留下 \(fixture.worktreeBase.path)") let afterBlockedCreate = try fixture.registeredWorktrees() #expect(afterBlockedCreate.count == 1, "被守卫拒掉的请求不该产生任何 git 登记,实际 \(afterBlockedCreate)") // 差分:唯一的差别是补上 Origin —— 于是走到业务逻辑并成功 let allowedCreate = try await rawJSONRequest( server: server, method: "POST", path: "projects/worktree", origin: server.origin, json: createBody) #expect(allowedCreate.status == 200, "带 Origin 的同一请求应 200,实际 \(allowedCreate.status)") let createdPath = try #require(allowedCreate.body["path"] as? String) let afterAllowedCreate = try fixture.registeredWorktree(atCanonicalPath: createdPath) #expect(afterAllowedCreate != nil, "带 Origin 的 create 应真的登记了 \(createdPath)") // Act + Assert(remove):无 Origin → 403,worktree 必须**还在** let removeBody: [String: Any] = [ "path": fixture.repo.path, "worktreePath": createdPath, "force": false, ] let blockedRemove = try await rawJSONRequest( server: server, method: "DELETE", path: "projects/worktree", origin: nil, json: removeBody) #expect(blockedRemove.status == 403, "缺 Origin 的 remove 应 403,实际 \(blockedRemove.status)") #expect(FileManager.default.fileExists(atPath: createdPath), "被守卫拒掉的 remove 绝不能真的删掉目录") let afterBlockedRemove = try fixture.registeredWorktree(atCanonicalPath: createdPath) #expect(afterBlockedRemove != nil, "被守卫拒掉的 remove 不该动 git 登记") // Act + Assert(prune):无 Origin → 403;带 Origin → 200 let blockedPrune = try await rawJSONRequest( server: server, method: "POST", path: "projects/worktree/prune", origin: nil, json: ["path": fixture.repo.path]) #expect(blockedPrune.status == 403, "缺 Origin 的 prune 应 403,实际 \(blockedPrune.status)") let allowedPrune = try await rawJSONRequest( server: server, method: "POST", path: "projects/worktree/prune", origin: server.origin, json: ["path": fixture.repo.path]) #expect(allowedPrune.status == 200, "带 Origin 的 prune 应 200,实际 \(allowedPrune.status)") // 收尾:带 Origin 把它删掉,证明 remove 的差分腿也通 let allowedRemove = try await rawJSONRequest( server: server, method: "DELETE", path: "projects/worktree", origin: server.origin, json: removeBody) #expect(allowedRemove.status == 200, "带 Origin 的 remove 应 200,实际 \(allowedRemove.status)") #expect(FileManager.default.fileExists(atPath: createdPath) == false) } // MARK: 5. 非法分支名 —— 400 且什么都没建 @Test("非法分支名:400 且**零副作用**(不建目录、不留 git 登记)") func invalidBranchNamesAreRejectedWithoutCreatingAnything() async throws { // Arrange:每一条都命中 validateBranchName 的一个分支 // (src/http/worktrees.ts:95-105):'..' / 前导 '-'(flag 注入) / 空白 / // '.lock' 结尾 / 前导 '/'。 let server = try await ServerHarness.shared.server() let fixture = try GitFixture.make() defer { fixture.destroy() } let client = makeClient(server) let invalidBranches = ["../evil", "-force-flag", "bad name", "ends.lock", "/leading-slash"] for branch in invalidBranches { // Act let outcome = try await client.createWorktree( path: fixture.repo.path, branch: branch, base: nil) // Assert:类型化拒绝 + 服务器的 SAFE 文案(绝非 git 原始 stderr) guard case let .rejected(status, message) = outcome else { Issue.record("分支 \(branch) 应被拒,实际 \(outcome)") continue } #expect(status == 400, "分支 \(branch) 应 400,实际 \(status)") #expect(message?.isEmpty == false, "400 应带一句可展示的原因,分支 \(branch)") } // Assert:五次拒绝之后,盘上和 git 里都一无所有 #expect(FileManager.default.fileExists(atPath: fixture.worktreeBase.path) == false, "非法分支名不该创建受控根 \(fixture.worktreeBase.path)") let registered = try fixture.registeredWorktrees() #expect(registered.count == 1, "应仍只有主 worktree,实际 \(registered)") #expect(fixture.administrativeEntries().isEmpty) } // MARK: 6. T-iOS-32 的另一半 —— GET /sessions(claude --resume 历史) @Test("GET /sessions:真 APIClient 对着**HOME 隔离**的真服务器,把 jsonl 解成 HistorySession") func claudeResumeHistoryDecodesFromTheRealServer() async throws { // Arrange:自建一个假 HOME —— `/sessions` 读的是 // `os.homedir()/.claude/projects`(src/http/history.ts:81)。不隔离的话, // CI 上那个目录不存在 ⇒ 恒 `[]` ⇒ 断言恒真;本机上又会把用户真实的会话 // 内容读进测试进程。两者都不可接受。 let manager = FileManager.default let home = manager.temporaryDirectory .appending(path: "webterm-history-home-\(UUID().uuidString)") .resolvingSymlinksInPath() defer { try? manager.removeItem(at: home) } let projectDir = home.appending(path: ".claude/projects/-fixture-demo-project") try manager.createDirectory(at: projectDir, withIntermediateDirectories: true) let sessionId = UUID().uuidString.lowercased() let lines: [[String: Any]] = [ ["type": "summary", "summary": "不是 user 行,应被跳过"], [ "type": "user", "cwd": "/fixture/demo-project", // content 的数组形态(parseSessionMeta 两种形态都要吃) "message": ["content": [["type": "text", "text": "resume me\tplease"]]], ], ] let jsonl = try lines .map { String(decoding: try JSONSerialization.data(withJSONObject: $0), as: UTF8.self) } .joined(separator: "\n") try jsonl.write( to: projectDir.appending(path: "\(sessionId).jsonl"), atomically: true, encoding: .utf8) let server = try await ServerHarness.disposableServer( extraEnvironment: ["HOME": home.path]) let client = makeClient(server) // Act:RO 路由 —— 真 APIClient 不带 Origin let sessions = try await client.claudeSessions() // Assert:逐字段解码(HOME 隔离 ⇒ 有且仅有我们放的那一条) #expect(sessions.count == 1, "隔离 HOME 下应恰好 1 条,实际 \(sessions.count)") let session = try #require(sessions.first) #expect(session.id == sessionId, "id 应是 jsonl 的文件名主干(claude --resume 拿它)") #expect(session.cwd == "/fixture/demo-project") #expect(session.project == "demo-project", "project = cwd 的末段") #expect(session.preview == "resume me please", "preview 应折叠空白后截断") #expect(session.mtimeMs > 0, "mtimeMs 是分数毫秒,必须解出正值") } }