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.
363 lines
20 KiB
Swift
363 lines
20 KiB
Swift
//
|
||
// 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),
|
||
// 连服务器派生出来的 `<repo>-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<Payload>(
|
||
_ outcome: GitWriteOutcome<Payload>, _ 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 是分数毫秒,必须解出正值")
|
||
}
|
||
}
|