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

@@ -0,0 +1,362 @@
//
// 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.tsvalidate 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 /sessionsclaude --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 是分数毫秒,必须解出正值")
}
}