Files
web-terminal/ios/IntegrationTests/WorktreeLifecycleTests.swift
Yaojia Wang ddab77b337 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.
2026-07-30 16:58:22 +02:00

363 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 是分数毫秒,必须解出正值")
}
}