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:
155
ios/IntegrationTests/GitFixture.swift
Normal file
155
ios/IntegrationTests/GitFixture.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
//
|
||||
// GitFixture.swift — T-iOS-32 端到端腿的**一次性 git 仓库**夹具
|
||||
//
|
||||
// 为什么必须是自建的临时仓库:`POST /projects/worktree` 是全应用**唯一**真正
|
||||
// 写盘的功能,服务器会在 `<dirname(repo)>/<basename(repo)>-worktrees/` 下真建
|
||||
// 目录、真开分支(src/http/worktrees.ts:146-160,224-256)。把它指向用户自己的
|
||||
// 仓库 = 在别人的工作区里留垃圾分支/目录。所以夹具把 repo **和**它派生出来的
|
||||
// worktree 根一起关在同一个临时目录里,`destroy()` 一把删干净。
|
||||
//
|
||||
// 布局(worktreeBase 的算法与服务端逐字对应,不是猜的):
|
||||
// <root>/repo ← 夹具仓库(一次空提交,HEAD 可用)
|
||||
// <root>/repo-worktrees/<sanitized> ← 服务器自己算出来的 worktree 目录
|
||||
//
|
||||
// 所有断言都拿 **git 自己**的 `worktree list --porcelain` 当第二信源:只信
|
||||
// HTTP 响应体的话,"服务器说建好了" 和 "盘上真建好了" 就分不开。
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// `git worktree list --porcelain` 里的一条记录(只留断言用得上的字段)。
|
||||
struct RegisteredWorktree: Sendable, Equatable {
|
||||
let path: String
|
||||
let branch: String?
|
||||
/// git 自己标记的"目录没了、登记还在"(`prunable <reason>`)。
|
||||
let prunable: Bool
|
||||
}
|
||||
|
||||
/// 跑一条 git 命令并返回合并后的输出;非零退出码抛 `HarnessError.setup`
|
||||
/// (夹具失败必须**响亮**,绝不能静默变成一条"断言不成立")。
|
||||
@discardableResult
|
||||
func runGit(_ arguments: [String], in directory: URL) throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = directory
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["GIT_TERMINAL_PROMPT"] = "0" // 夹具永远不联网、永远不问密码
|
||||
process.environment = environment
|
||||
try process.run()
|
||||
// 先读到 EOF 再 wait:反过来会在输出撑满管道缓冲时死锁。
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
let text = String(decoding: data, as: UTF8.self)
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw HarnessError.setup(
|
||||
"git \(arguments.joined(separator: " ")) 退出码 \(process.terminationStatus): \(text)")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/// 一个临时目录里的一次性 git 仓库。值语义 + 显式 `destroy()`(用例 `defer` 调)。
|
||||
struct GitFixture: Sendable {
|
||||
/// 临时根目录,**已解 symlink**(macOS 的 $TMPDIR 是 /var/… → /private/var/…;
|
||||
/// 不解的话服务端 realpath 之后返回的路径与我们手上的字符串对不上)。
|
||||
let root: URL
|
||||
/// 夹具仓库本体(`<root>/repo`)。
|
||||
let repo: URL
|
||||
|
||||
/// 服务端在 `worktreeRoot` 未配置时算出来的 worktree 根:
|
||||
/// `<dirname(repo)>/<basename(repo)>-worktrees`(src/http/worktrees.ts:151-152)。
|
||||
var worktreeBase: URL { root.appending(path: "\(repo.lastPathComponent)-worktrees") }
|
||||
|
||||
/// 提交身份走 `-c`:不读、不写用户的全局 git 配置,也不触发 gpg 签名。
|
||||
private static let identity = [
|
||||
"-c", "user.email=fixture@example.invalid",
|
||||
"-c", "user.name=WebTerm Fixture",
|
||||
"-c", "commit.gpgsign=false",
|
||||
]
|
||||
|
||||
/// 建仓 + 一次空提交(`git worktree add` 在 unborn HEAD 上会失败,所以必须有)。
|
||||
static func make() throws -> GitFixture {
|
||||
let manager = FileManager.default
|
||||
let raw = manager.temporaryDirectory
|
||||
.appending(path: "webterm-worktree-e2e-\(UUID().uuidString)")
|
||||
try manager.createDirectory(at: raw, withIntermediateDirectories: true)
|
||||
// 一次性钉成规范形式(macOS 的 $TMPDIR 是 /var/… → /private/var/…)。服务端
|
||||
// 拿到的就是规范路径,它算出来的 worktree 目录、git 报回来的路径三者同形。
|
||||
let root = URL(fileURLWithPath: canonicalPath(raw.path), isDirectory: true)
|
||||
let repo = root.appending(path: "repo")
|
||||
try manager.createDirectory(at: repo, withIntermediateDirectories: true)
|
||||
try runGit(["-c", "init.defaultBranch=main", "init", "-q"], in: repo)
|
||||
try runGit(identity + ["commit", "-q", "--allow-empty", "-m", "fixture"], in: repo)
|
||||
return GitFixture(root: root, repo: repo)
|
||||
}
|
||||
|
||||
/// 尽力而为的清理:repo 与它的 worktree 根都在 `root` 底下,一把删完。
|
||||
/// 吞错是刻意的 —— 清理失败只留一个临时目录,不该把用例判定改写掉。
|
||||
func destroy() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
// ── 第二信源:git 自己的登记 ────────────────────────────────────────────
|
||||
|
||||
/// 解析 `git worktree list --porcelain`(空行分隔的记录,首条是主 worktree)。
|
||||
func registeredWorktrees() throws -> [RegisteredWorktree] {
|
||||
let porcelain = try runGit(["worktree", "list", "--porcelain"], in: repo)
|
||||
var out: [RegisteredWorktree] = []
|
||||
var path: String?
|
||||
var branch: String?
|
||||
var prunable = false
|
||||
func flush() {
|
||||
guard let path else { return }
|
||||
out.append(RegisteredWorktree(path: path, branch: branch, prunable: prunable))
|
||||
(branch, prunable) = (nil, false)
|
||||
}
|
||||
for rawLine in porcelain.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
let line = String(rawLine)
|
||||
if line.isEmpty {
|
||||
flush()
|
||||
path = nil
|
||||
continue
|
||||
}
|
||||
if line.hasPrefix("worktree ") {
|
||||
path = String(line.dropFirst("worktree ".count))
|
||||
} else if line.hasPrefix("branch refs/heads/") {
|
||||
branch = String(line.dropFirst("branch refs/heads/".count))
|
||||
} else if line == "prunable" || line.hasPrefix("prunable ") {
|
||||
prunable = true
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/// 按规范路径找一条登记(symlink 别名与规范路径必须算同一个)。
|
||||
func registeredWorktree(atCanonicalPath target: String) throws -> RegisteredWorktree? {
|
||||
let wanted = canonicalPath(target)
|
||||
return try registeredWorktrees().first { canonicalPath($0.path) == wanted }
|
||||
}
|
||||
|
||||
/// `.git/worktrees/` 下的行政登记目录名(prune 要清掉的正是这些)。
|
||||
func administrativeEntries() -> [String] {
|
||||
let dir = repo.appending(path: ".git/worktrees")
|
||||
let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path)
|
||||
return (names ?? []).sorted()
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范绝对路径 = `realpath(3)`;路径不存在时**原样返回**。
|
||||
///
|
||||
/// 为什么不能用 `URL.resolvingSymlinksInPath()`(实测踩过,本文件的 RED 就是它):
|
||||
/// 它的文档行为是「若路径以 /private 开头就**去掉** /private」,方向正好相反 ——
|
||||
/// 于是夹具根停在 `/var/folders/…`,而 git 的 `worktree list` 报的是
|
||||
/// `/private/var/folders/…`。更糟的是那层转换**依赖路径是否存在**:目录被删掉之后
|
||||
/// `/private` 不再被剥离,于是"删干净了吗"这类断言会因为路径形状而不是因为事实
|
||||
/// 失败。`realpath(3)` 只做一个方向(→ 规范形式),夹具在建立时就把根钉成规范形式,
|
||||
/// 此后所有比较都是纯字符串相等,与"路径此刻还存不存在"无关。
|
||||
func canonicalPath(_ path: String) -> String {
|
||||
guard let resolved = realpath(path, nil) else { return path }
|
||||
defer { free(resolved) }
|
||||
return String(cString: resolved)
|
||||
}
|
||||
Reference in New Issue
Block a user