Files
vibe-foreman/HubKit/Tests/HubUITests/AcceptanceStoreTests.swift
T
wuguanganandClaude Opus 5 a5ba3f7573 feat: Hub 自己跑验收命令 —— 实际功能证明
阶段 4 的教训直接催生了这一步:diff 复核把 `case ran(command:exitCode:)`
这个**枚举声明**当成了功能已实现,而没有任何代码产生它。读代码判断不了
"功能是否真的能用",只有跑一遍能。

执行者必须是 Hub 而不是 Claude —— 让它跑完再转述,就又回到了「说说而已」,
转述那一步正是失真发生的地方。产出 Evidence.ran(isProof = true)。

三道闸,全部有测试,元字符那道做过变异验证:

1. 白名单:只放行构建/测试类。刻意排除 npx / pnpm dlx(会下载执行任意包,
   放行它等于白名单形同虚设)、npm install(跑 postinstall 等于执行任意
   代码)、git 的写操作。bash 限定在项目的 scripts/ 下。
2. 元字符:16 个字符逐个测。**这道检查必须在白名单之前** ——
   变异验证时把它挪到后面,`swift test | tee /tmp/x` 立刻因为前缀匹配上
   `swift test` 而被放行。配合 Shell.run 走 execve 数组参数(不是 sh -c)。
3. 总开关默认 off + 逐条授权。授权按命令原文精确匹配,改一个字要重新授权;
   而且手工往 authorized 里塞危险命令也绕不过前两道(单独一条测试守)。

顺带修了注入正文太长:真实项目上拦了 12 条带完整验收条件,糊了满屏。
要点一多就没人看,反而比不拦更容易被忽略。上限 6 条,存疑的排前面,
省略了多少明确说出来。

Shell.run 加 currentDirectory —— 验收命令必须在项目目录里跑,
否则 swift test 找不到 Package.swift。

388 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:37:45 +08:00

416 lines
17 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.
import HubCore
import XCTest
@testable import HubUI
/// 全部用临时目录。理由同 ApprovalCoordinatorTests:
/// 用默认路径会直接改写用户真实的验收清单。
@MainActor
final class AcceptanceStoreTests: XCTestCase {
private var tempDir: URL!
override func setUpWithError() throws {
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("acceptance-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempDir)
}
private let project = "/tmp/demo-project"
private func pendingItem(_ text: String = "移动端能正常翻页") -> AcceptanceItem {
AcceptanceItem(text: text, origin: .userPrompt)
}
// MARK: - 上膛:防死循环的核心护栏
/// **拦截只能由用户上膛,Claude 无法给自己上膛。**
///
/// 这是全案唯一能把用户会话卡死的地方:Stop 拦一次 → Claude 回一轮 → 又 Stop,
/// 如果那时还能再拦,就是无限循环。
///
/// 这条测试断言的是「连着两次 Stop,第二次必不拦」。它守的不是某个 if 写对了,
/// 而是 `disarmAndShouldIntercept` 里那句**先卸膛再判断**的顺序 ——
/// 把 `armedSessions.remove` 删掉或挪到 return 之后,这条必须变红。
func testOnlyTheUserCanArmInjection() {
let store = AcceptanceStore(directory: nil)
store.add(pendingItem(), to: project)
store.arm(sessionId: "s1")
XCTAssertTrue(
store.disarmAndShouldIntercept(sessionId: "s1", projectPath: project),
"用户说过话且有待办 —— 这一次应该拦"
)
XCTAssertFalse(
store.disarmAndShouldIntercept(sessionId: "s1", projectPath: project),
"没有新的用户输入就绝不能再拦,否则 Claude 回一轮就触发一次,会话卡死"
)
}
/// 从没上过膛的会话(用户一句话没说)永远不拦。
func testNeverInterceptsWithoutUserInput() {
let store = AcceptanceStore(directory: nil)
store.add(pendingItem(), to: project)
XCTAssertFalse(store.disarmAndShouldIntercept(sessionId: "never-armed", projectPath: project))
}
/// 上膛是按会话隔离的 —— 在 A 会话说话不该让 B 会话被拦。
func testArmingIsPerSession() {
let store = AcceptanceStore(directory: nil)
store.add(pendingItem(), to: project)
store.arm(sessionId: "a")
XCTAssertFalse(store.disarmAndShouldIntercept(sessionId: "b", projectPath: project))
XCTAssertTrue(store.disarmAndShouldIntercept(sessionId: "a", projectPath: project))
}
/// 清单为空的项目永远不拦。
///
/// 没启用这功能的项目必须**完全无感** —— 否则装上 Hub 的人会发现
/// 每个项目收工都被塞一段莫名其妙的东西。
func testEmptyLedgerNeverIntercepts() {
let store = AcceptanceStore(directory: nil)
store.arm(sessionId: "s1")
XCTAssertFalse(store.disarmAndShouldIntercept(sessionId: "s1", projectPath: project))
XCTAssertNil(store.injectionText(for: project))
}
/// 全部都已经有定论时也不拦。
func testFullySettledLedgerDoesNotIntercept() {
let store = AcceptanceStore(directory: nil)
var item = pendingItem()
item.status = .accepted
store.add(item, to: project)
store.arm(sessionId: "s1")
XCTAssertFalse(store.disarmAndShouldIntercept(sessionId: "s1", projectPath: project))
}
// MARK: - 用户终裁不可被机器覆盖
/// **用户勾了「接受」之后,旁路复核判什么都不许改回去。**
///
/// 这是对用户的产品承诺("最终勾选权在你手上")。没有这条的话,
/// 用户勾完一转身就被 auditor 打回 disputed,那个勾等于白点。
func testUserVerdictSurvivesTheAuditor() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.setStatus(.accepted, forID: item.id, in: project)
store.applyAudit(
[AcceptanceVerdict(id: item.id, confirmed: false, note: "diff 里找不到")],
in: project
)
XCTAssertEqual(store.ledger(for: project).items.first?.status, .accepted)
}
/// 用户划掉的项同样不许被机器改。
func testDroppedItemSurvivesTheAuditor() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.setStatus(.dropped, forID: item.id, in: project)
store.applyAudit([AcceptanceVerdict(id: item.id, confirmed: true)], in: project)
XCTAssertEqual(store.ledger(for: project).items.first?.status, .dropped)
}
/// 自查回答同样不能覆盖用户终裁。
func testClaimsCannotOverrideUserVerdict() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.setStatus(.accepted, forID: item.id, in: project)
store.applyClaims(
[AcceptanceClaim(id: item.id, done: true, evidence: "改了 a.swift")], in: project
)
XCTAssertEqual(store.ledger(for: project).items.first?.status, .accepted)
}
// MARK: - 自述不是证明
/// **Claude 自报做完,只能推到「待复核」,不能算证明。**
///
/// 这条守的是用户那句"不是说说而已"。`applyClaims` 若把状态直接推到
/// `.confirmed`、或把证据记成 `.diff` / `.ran`,这条必须变红。
func testSelfReportIsNeverCountedAsProof() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.applyClaims(
[AcceptanceClaim(id: item.id, done: true, evidence: "改了 x.swift,跑了测试")],
in: project
)
let ledger = store.ledger(for: project)
XCTAssertEqual(ledger.items.first?.status, .claimed, "自报只能到「待复核」")
XCTAssertFalse(ledger.items.first?.hasProof ?? true, "自述不是证明")
XCTAssertEqual(ledger.provenCount, 0, "汇报条的「已证」不许把自述算进去")
}
/// Hub 自己跑出来的才进「已证」。
func testOnlyHubProducedEvidenceCounts() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.applyAudit(
[AcceptanceVerdict(
id: item.id, confirmed: true,
evidence: [.diff(path: "Sources/A.swift", added: 12, removed: 3)]
)],
in: project
)
XCTAssertEqual(store.ledger(for: project).provenCount, 1)
}
/// 它自己承认没做的项要留在「未验收」,别悄悄推进状态。
func testAdmittedIncompleteStaysOpen() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.applyClaims(
[AcceptanceClaim(id: item.id, done: false, evidence: "时间不够,没动")], in: project
)
let stored = store.ledger(for: project).items.first
XCTAssertEqual(stored?.status, .open)
XCTAssertEqual(stored?.note, "时间不够,没动")
}
// MARK: - 注入正文
func testInjectionTextCarriesIDsAndAcceptanceConditions() {
let store = AcceptanceStore(directory: nil)
let item = AcceptanceItem(
text: "移动端能正常翻页", acceptance: "npm run test:e2e", origin: .plan
)
store.add(item, to: project)
let text = try? XCTUnwrap(store.injectionText(for: project))
XCTAssertEqual(text?.contains(item.id), true, "没有 id,回答就没法对号入座")
XCTAssertEqual(text?.contains("移动端能正常翻页"), true)
XCTAssertEqual(text?.contains("npm run test:e2e"), true)
}
/// 注入正文里必须点明「不是你自己列的 todo」。
///
/// 少了这句,Claude 会拿自己的 TodoWrite 来对照 —— 而那份清单正是
/// 遗漏发生的地方,对照它等于整个功能白做。
func testInjectionTextDistinguishesItselfFromClaudesOwnTodos() {
let store = AcceptanceStore(directory: nil)
store.add(pendingItem(), to: project)
XCTAssertEqual(store.injectionText(for: project)?.contains("不是你自己列的 todo"), true)
}
/// 一次最多摆 6 条,而且**省略了多少必须说出来**。
///
/// 实测在一个真实项目上拦了 12 条,带完整验收条件之后糊了满屏 ——
/// 要点一多就没人看(Claude 也一样),反而比不拦更容易被忽略。
/// 静默截断更糟:会让人以为"就这几条"。
func testInjectionTextIsCappedAndSaysHowManyWereOmitted() {
let store = AcceptanceStore(directory: nil)
for index in 0..<12 {
store.add(AcceptanceItem(text: "要点 \(index)", origin: .userPrompt), to: project)
}
let text = store.injectionText(for: project) ?? ""
let listed = text.split(separator: "\n").filter { $0.hasPrefix("- [") }.count
XCTAssertEqual(listed, 6)
XCTAssertTrue(text.contains("另有 6 条未列出"), "省略了多少必须说出来")
}
/// 存疑的必须排在注入正文最前面。
///
/// 那一档是「自报做完但代码里找不到」—— 最该先说清楚的一类,
/// 排在后面会被 6 条的上限挤掉。
func testDisputedItemsComeFirstInTheInjection() {
let store = AcceptanceStore(directory: nil)
for index in 0..<8 {
store.add(AcceptanceItem(text: "普通要点 \(index)", origin: .userPrompt), to: project)
}
let suspicious = AcceptanceItem(text: "这条自报做了但找不到", origin: .userPrompt)
store.add(suspicious, to: project)
store.setStatus(.disputed, forID: suspicious.id, in: project)
let text = store.injectionText(for: project) ?? ""
XCTAssertTrue(
text.contains("这条自报做了但找不到"),
"存疑项被 6 条上限挤掉了 —— 它恰恰是最该先说的"
)
}
/// 已经有定论的项不该出现在注入正文里。
func testInjectionTextOmitsSettledItems() {
let store = AcceptanceStore(directory: nil)
let done = AcceptanceItem(text: "这条早就做完了", origin: .manual)
let todo = AcceptanceItem(text: "这条还没做", origin: .manual)
store.add(done, to: project)
store.add(todo, to: project)
store.setStatus(.accepted, forID: done.id, in: project)
let text = store.injectionText(for: project)
XCTAssertEqual(text?.contains("这条还没做"), true)
XCTAssertEqual(text?.contains("这条早就做完了"), false)
}
// MARK: - 去重
func testMergeSkipsDuplicatesRegardlessOfPunctuation() {
let store = AcceptanceStore(directory: nil)
store.add(AcceptanceItem(text: "修复 apple-header sticky", origin: .userPrompt), to: project)
store.merge(
[AcceptanceItem(text: "修复 apple header sticky。", origin: .plan)], into: project
)
XCTAssertEqual(store.ledger(for: project).items.count, 1)
}
/// 用户划掉过的要点,不能因为又被拆出来一次就复活。
func testMergeDoesNotResurrectDroppedItems() {
let store = AcceptanceStore(directory: nil)
let item = AcceptanceItem(text: "加一个深色模式", origin: .userPrompt)
store.add(item, to: project)
store.setStatus(.dropped, forID: item.id, in: project)
store.merge([AcceptanceItem(text: "加一个深色模式", origin: .plan)], into: project)
let ledger = store.ledger(for: project)
XCTAssertEqual(ledger.items.count, 1)
XCTAssertEqual(ledger.items.first?.status, .dropped)
}
// MARK: - 持久化
func testSurvivesRestart() {
let first = AcceptanceStore(directory: tempDir)
first.add(pendingItem("跨会话要还在"), to: project)
let reloaded = AcceptanceStore(directory: tempDir)
XCTAssertEqual(reloaded.ledger(for: project).items.first?.text, "跨会话要还在")
}
func testNilDirectoryDoesNotTouchTheDisk() throws {
let store = AcceptanceStore(directory: nil)
store.add(pendingItem(), to: project)
XCTAssertEqual(store.ledger(for: project).items.count, 1, "内存里仍要生效")
let files = try FileManager.default.contentsOfDirectory(
at: tempDir, includingPropertiesForKeys: nil
)
XCTAssertTrue(files.isEmpty)
}
/// **外部手改文件后,app 的下一次写入不能把它盖掉。**
///
/// 这条来自一次真实事故:我用脚本往清单文件里写了 9 条要点,而 app 正开着、
/// 内存里还是旧的一条。app 那边随便点一下,9 条就没了 —— 无声无息。
/// 清单是给人看也给人改的,手工编辑 JSON 是合理用法,不能是陷阱。
func testExternalEditsAreNotClobberedByTheNextWrite() throws {
let store = AcceptanceStore(directory: tempDir)
store.add(pendingItem("app 加的"), to: project)
// 模拟"app 开着的时候有人在外面改了文件"
let external = AcceptanceStore(directory: tempDir)
external.add(pendingItem("外面加的"), to: project)
// 第一个 store 内存里还是旧的一条,此时它写一次盘
store.add(pendingItem("app 后来又加的"), to: project)
let final = AcceptanceStore(directory: tempDir)
let texts = final.ledger(for: project).items.map(\.text)
XCTAssertTrue(texts.contains("外面加的"), "外部改动被盖掉了:\(texts)")
XCTAssertTrue(texts.contains("app 加的"))
XCTAssertTrue(texts.contains("app 后来又加的"))
}
/// 单个文件坏掉不能让整个功能失效 —— 清单是边跑边写的,
/// 撞上半截 JSON 是正常情况。
func testCorruptFileIsSkippedWithoutLosingTheOthers() throws {
let good = AcceptanceStore(directory: tempDir)
good.add(pendingItem("好的那份"), to: project)
try "这不是 json".data(using: .utf8)!
.write(to: tempDir.appendingPathComponent("broken.json"))
let reloaded = AcceptanceStore(directory: tempDir)
XCTAssertEqual(reloaded.ledger(for: project).items.first?.text, "好的那份")
}
// MARK: - 汇报级统计
func testHeadlineLeadsWithWhatNeedsAttention() {
let store = AcceptanceStore(directory: nil)
let disputed = pendingItem("存疑的")
store.add(disputed, to: project)
store.add(pendingItem("没做的"), to: project)
store.setStatus(.disputed, forID: disputed.id, in: project)
let headline = store.ledger(for: project).headline
XCTAssertTrue(headline.hasPrefix("存疑 1"), "存疑必须排最前:实际输出是「\(headline)」")
XCTAssertTrue(headline.contains("未验收 1"))
XCTAssertTrue(headline.contains("已证 0/2"))
}
// MARK: - 导出
/// 只有自述的项,报告里必须如实写「未经实测验证」。
///
/// 报告是要留档、要发给别人的。把自述渲染得像已验证,
/// 等于把这个功能要防的问题原样搬进了归档。
func testReportMarksSelfReportedItemsAsUnverified() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.applyClaims(
[AcceptanceClaim(id: item.id, done: true, evidence: "改了 x.swift")], in: project
)
let report = store.markdownReport(for: project, projectName: "demo")
XCTAssertTrue(report.contains("仅自述,未经实测验证"))
}
func testReportIncludesCommandOutput() {
let store = AcceptanceStore(directory: nil)
let item = pendingItem()
store.add(item, to: project)
store.applyAudit(
[AcceptanceVerdict(
id: item.id, confirmed: true,
evidence: [.ran(command: "swift test", exitCode: 0, tail: "265 tests passed")]
)],
in: project
)
let report = store.markdownReport(for: project, projectName: "demo")
XCTAssertTrue(report.contains("swift test"))
XCTAssertTrue(report.contains("265 tests passed"))
}
}